refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack

将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-29 01:11:20 +08:00
parent e8693dad2a
commit dd3eb24d0f
488 changed files with 33927 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# =============================================================
# Claude Dev Stack 配置文件
# 复制为 .env 并按需填写
# =============================================================
# ── Claude / API 配置 ────────────────────────────────────────
# 推荐:灵眸 AI国内直连无需代理https://docs.lmuai.com/docs/tools/claude-code
ANTHROPIC_AUTH_TOKEN=sk-你的灵眸API密钥
ANTHROPIC_BASE_URL=https://api.lmuai.com
# 备选Anthropic 官方 API Key海外直连
# ANTHROPIC_API_KEY=
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# 备选DeepSeek 兼容接口
# ANTHROPIC_API_KEY=
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# 默认使用的模型
# 灵眸可选claude-opus-4-7 | claude-sonnet-4-6 | claude-sonnet-4-5 | claude-haiku-4-5
# DeepSeek可选deepseek-v4-pro
CLAUDE_MODEL=claude-sonnet-4-6
# ── WSL2 ────────────────────────────────────────────────────
# WSL2 发行版名称wsl --list 查看已安装发行版)
WSL_DISTRO=Ubuntu
# 设为 true 可跳过 WSL2 安装步骤(已安装时使用)
SKIP_WSL_INSTALL=false
# ── Unity MCP ───────────────────────────────────────────────
# unity-mcp-server 自动克隆至 WSL2 ~/.mcp-servers/unity-mcp-server/
# Unity Plugin 需手动通过 Package Manager 安装:
# https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git
# (无需额外配置)
# ── Rust / Token Killer ──────────────────────────────────────
# (暂无需配置,预留扩展用)
# CARGO_REGISTRY_MIRROR=https://rsproxy.cn/
# ── Docker 镜像加速(可选)──────────────────────────────────
# 若 WSL2 内需要 Docker可配置国内加速镜像逗号分隔
# DOCKER_REGISTRY_MIRRORS=https://docker.m.daocloud.io,https://hub-mirror.c.163.com

2
wsl-dev-stack/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
node_modules/

729
wsl-dev-stack/README.md Normal file
View File

@@ -0,0 +1,729 @@
# Claude Dev Stack
WSL2 + Claude Code CLI + GitHub Copilot CLI + Unity MCP + **Godot MCP Pro** + Rust Token Killer **全栈一键部署方案**,适用于 Windows 11 开发环境。
支持 **WSL2 镜像网络模式**`autoProxy=true`WSL2 自动继承 Windows 代理状态,无需脚本干预。
## 组件清单
| 组件 | 说明 | 安装位置 |
|------|------|---------|
| **WSL2** | Windows Subsystem for Linux 2 | Windows 功能 |
| **Claude Code CLI** | `@anthropic-ai/claude-code` | WSL2 npm global |
| **GitHub Copilot CLI** | `gh` + `github/gh-copilot` 扩展 | WSL2 apt + gh extension |
| **Unity MCP Server** | `AnkleBreaker-Studio/unity-mcp-server` — Claude ↔ Unity Editor 双向集成 | WSL2 独立 Node.js 服务 + Unity Plugin |
| **Godot MCP Pro** | 本地 ZIP 包v1.14.1)— Claude ↔ Godot Editor 双向集成170+ 工具 | `~/godot-mcp-pro/`Win+ `~/.mcp-servers/godot-mcp-pro/`WSL2 |
| **Rust** | rustup stable 工具链 | WSL2 `~/.cargo` |
| **RTK** | Rust Token Killer (`rtk`) — LLM token 统计与上下文优化 | WSL2 cargo bin |
## 目录结构
```
claude-dev-stack/
├── deploy.ps1 # Windows 一键部署PowerShell 5.1+
├── wsl-setup.sh # WSL2 内部安装脚本(可单独运行)
├── .env.example # 配置模板
├── godot-mcp-pro-v1.14.1/ # Godot MCP Pro 本地包(付费,随仓库分发)
│ ├── server/ # Node.js MCP Serverbuild/ 已预编译)
│ ├── addons/godot_mcp/ # Godot 插件(手动复制到各项目)
│ └── INSTALL.md
└── README.md
```
---
## 快速开始
### GitHub Copilot CLI 共存说明
本方案安装的是 **WSL2 Linux 原生 GitHub CLI + gh-copilot 扩展**
即使 Windows 已经安装了 GitHub Copilot CLI / GitHub CLI也**不会覆盖 Windows 版本**。
- Windows 继续使用 Windows 自己的 `gh`
- WSL2 继续使用 WSL2 自己的 `gh`
- 两边各自维护独立的登录状态和配置,互不冲突
- `deploy.ps1` 会额外写入 PowerShell 快捷命令 `gh-copilot-wsl`,用于**显式调用 WSL2 版本**,避免误用 Windows 侧命令
### WSL2 默认用户
本方案使用 **root** 作为 WSL2 默认用户(通过 `/etc/wsl.conf` 配置)。
WSL2 Ubuntu 首次启动若弹出用户创建提示可直接跳过或按提示操作deploy.ps1 会自动将默认用户切换为 root。
---
### 场景一:全新 Windows 系统(首次安装 WSL2
> 需要**管理员权限**启用 WSL2 Windows 功能
```powershell
# 1. 以管理员身份运行 PowerShell
cd path\to\claude-dev-stack
# 2. 复制配置文件,填写灵眸 API Key推荐或 Anthropic API Key
cp .env.example .env
notepad .env
# 3. 运行部署脚本
pwsh .\deploy.ps1
```
脚本会自动在 **WSL2 内** 安装以下组件:
1. Claude Code CLI
2. GitHub CLI (`gh`)
3. GitHub Copilot CLI 扩展 (`github/gh-copilot`)
4. Unity MCP Server
5. Godot MCP Pro从本地 `godot-mcp-pro-v1.14.1/` 复制)
6. Rust + RTK
首次安装 WSL2 特性后可能需要**重启系统**,重启后重新执行脚本。
### 场景二:已有 WSL2跳过 WSL2 安装
```powershell
pwsh .\deploy.ps1 -SkipWSL
```
### 场景三:仅在 WSL2 内安装(无 Windows 脚本)
```bash
# 在 WSL2 Ubuntu 终端中执行
cp .env.example .env
nano .env
bash wsl-setup.sh
```
---
## 代理说明
### WSL2 镜像模式自动继承 Windows 代理
> **注意**:镜像网络模式仅支持 **Windows 11 22H2Build 22621及以上版本**。Windows 10 用户脚本会自动跳过此配置WSL2 使用默认 NAT 模式,需在 WSL2 内手动配置代理。
脚本自动配置 `~/.wslconfig` 启用 WSL2 镜像网络模式:
```ini
[wsl2]
networkingMode=mirrored
autoProxy=true
```
`autoProxy=true`WSL2 自动继承 Windows 系统代理状态。
**是否使用代理由 Windows 侧决定**(例如通过 v2rayN「设为系统代理」无需脚本干预。
---
## 配置说明(`.env`
### 推荐:灵眸 AI国内直连无需代理
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ANTHROPIC_AUTH_TOKEN` | _(必填)_ | 灵眸 API Key从 [lmuai.com](https://lmuai.com) 获取 |
| `ANTHROPIC_BASE_URL` | `https://api.lmuai.com` | 灵眸 API 基础 URL |
| `CLAUDE_MODEL` | `claude-sonnet-4-6` | 可选:`claude-sonnet-4-6` / `claude-opus-4-7` / `claude-sonnet-4-5` / `claude-haiku-4-5` |
> **灵眸模式自动写入 `settings.json` 的 env 参数**
> | 参数 | 值 | 说明 |
> |------|----|------|
> | `ANTHROPIC_BASE_URL` | `https://api.lmuai.com` | API 端点 |
> | `ANTHROPIC_AUTH_TOKEN` | _(你的 Key)_ | 认证凭据 |
> | `API_TIMEOUT_MS` | `3000000` | 超时 50 分钟,防止长任务中断 |
> | `CLAUDE_CODE_ATTRIBUTION_HEADER` | `0` | 关闭来源标识,提高缓存命中率 |
> | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` | 关闭遥测/自动更新检查 |
>
> - Token 写入 `settings.json` 的 `env` 块(不写入 `~/.bashrc`,避免与 `ANTHROPIC_API_KEY` 冲突)
> - 旧 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` 变量从 `~/.bashrc` 自动清除
### 备选Anthropic 官方 API
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ANTHROPIC_API_KEY` | _(空)_ | Anthropic API Key从 [console.anthropic.com](https://console.anthropic.com) 获取 |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | API 基础 URLDeepSeek 等中转可改为对应 URL |
| `CLAUDE_MODEL` | `claude-sonnet-4-6` | 默认使用的模型 |
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `WSL_DISTRO` | `Ubuntu` | WSL2 发行版名称 |
| `SKIP_WSL_INSTALL` | `false` | `true` 跳过 WSL2 安装步骤 |
---
## Unity MCP 完整安装教程
> **MCP Server 仓库**[AnkleBreaker-Studio/unity-mcp-server](https://github.com/AnkleBreaker-Studio/unity-mcp-server)
> **Unity Plugin 仓库**[AnkleBreaker-Studio/unity-mcp-plugin](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin)
https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git
> **要求**Unity 2021.3+ + Node.js 18+
### 架构原理
Unity MCP 由**两个独立仓库**组成,各自独立安装:
```
Claude Code CLI
│ MCP 协议 (stdio)
Node.js MCP Server ← 克隆自 unity-mcp-serverWSL2 中运行
(~/.mcp-servers/unity-mcp-server/) ← deploy.ps1 自动完成克隆 + npm install
│ WebSocket / HTTP
Unity Editor Plugin (C#) ← Package Manager 安装 unity-mcp-plugin
Unity 场景 / 对象 / 脚本 / 测试...
```
- **unity-mcp-server**:独立 Node.js MCP 服务,部署脚本自动克隆并安装依赖
- **unity-mcp-plugin**Unity C# 插件,需要手动通过 Package Manager 安装
---
### Step 1运行部署脚本自动完成服务器安装
```powershell
pwsh .\deploy.ps1
```
脚本会自动:
1. 克隆 `unity-mcp-server` 到 WSL2 `~/.mcp-servers/unity-mcp-server/`
2. 执行 `npm install` 安装依赖
3. 将 MCP Server 注册到 `%USERPROFILE%\.claude\claude_desktop_config.json`
---
### Step 2在 Unity Editor 安装 unity-mcp-plugin
1. 打开 Unity Editor
2. 菜单:**Window → Package Manager**
3. 点击左上角 **"+"** 按钮 → **"Add package from git URL..."**
4. 输入以下 URL点击 **Add**
```
https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git
```
5. 等待包下载安装完成
安装完成后,菜单栏会出现 **Tools → Unity MCP** 选项。
---
### Step 3启动 Unity Editor 端 MCP 服务
每次使用 Claude Code 操作 Unity 之前,需要先在 Unity 中启动服务:
1. 打开 Unity Editor确保项目已加载
2. 菜单:**Tools → Unity MCP → Start Server**(具体菜单项名称以插件实际为准)
3. 状态变为绿色 / Connected 表示服务就绪
---
### Step 4确认 MCP 配置
部署脚本已自动写入 Windows Claude Code 配置。确认内容:
```powershell
cat "$env:USERPROFILE\.claude\claude_desktop_config.json"
```
应包含类似如下内容:
```json
{
"mcpServers": {
"unity-mcp": {
"command": "wsl",
"args": ["-d", "Ubuntu", "--", "node", "/home/用户名/.mcp-servers/unity-mcp-server/src/index.js"]
}
}
}
```
WSL2 内的配置(`~/.config/Claude/claude_desktop_config.json`)直接使用 `node`
```json
{
"mcpServers": {
"unity-mcp": {
"command": "node",
"args": ["/home/用户名/.mcp-servers/unity-mcp-server/src/index.js"]
}
}
}
```
---
### Step 5在 Claude Code 中使用 Unity MCP
```bash
# 启动 Claude CodeWSL2 内)
claude
# 验证 Unity MCP 连接
> /mcp
# 示例:操作 Unity 场景
> 在场景中创建一个名为 Player 的空 GameObject
> 给 Player 添加 Rigidbody 组件,质量设为 5
> 创建一个新场景 Level1保存到 Assets/Scenes/
> 运行所有 EditMode 测试
```
---
### 可用 MCP 工具列表
AnkleBreaker Unity MCP 采用**两级工具架构**,共 **288 个工具**
- **核心工具(~70个**:直接暴露,无需中转
- **高级工具200+**:通过 `unity_advanced_tool` 代理访问(避免 MCP 客户端工具过多失效)
#### 核心工具(直接可用)
| 分类 | 工具名 | 功能 |
|------|--------|------|
| **编辑器状态** | `unity_editor_ping` / `unity_editor_state` / `unity_project_info` | 检测连接、获取编辑器/项目状态 |
| **场景** | `unity_scene_info/open/save/new/hierarchy/stats` | 场景完整生命周期管理 |
| **GameObject** | `unity_gameobject_create/delete/info/set_transform/duplicate/set_active/reparent` | 对象增删改查 |
| **组件** | `unity_component_add/remove/get_properties/set_property/set_reference/batch_wire` | 组件与属性操作 |
| **资产** | `unity_asset_list/import/delete/create_prefab/instantiate_prefab` | 资产管理 |
| **脚本** | `unity_script_create/read/update` + `unity_execute_code` | C# 脚本读写与运行时执行 |
| **材质** | `unity_material_create` / `unity_renderer_set_material` | 材质创建与赋值 |
| **构建/运行** | `unity_build` / `unity_play_mode` | 多平台构建、Play Mode 控制 |
| **控制台** | `unity_console_log/clear` + `unity_get_compilation_errors` | 日志读取与编译错误 |
| **编辑器操作** | `unity_execute_menu_item` / `unity_undo/redo/undo_history` | 菜单执行、撤销重做 |
| **选择/搜索** | `unity_selection_*` / `unity_search_by_*` / `unity_search_assets` | 对象查找与选中 |
| **截图** | `unity_screenshot_game/scene` + `unity_graphics_*_capture` | 场景/游戏视图截图 |
| **Prefab** | `unity_prefab_info` / `unity_set_object_reference` | Prefab 信息与引用 |
| **包管理** | `unity_packages_list/add/remove/search/info` | Package Manager 操作 |
| **Multi-Agent** | `unity_queue_info` / `unity_agents_list` / `unity_agent_log` | 多代理会话管理 |
| **高级工具代理** | `unity_list_advanced_tools` / `unity_advanced_tool` | 访问 200+ 高级工具 |
| **Unity Hub** | `unity_hub_list_editors/available_releases/install_editor/install_modules` | Hub 版本管理 |
| **多实例** | `unity_list_instances` / `unity_select_instance` | 多 Unity 实例切换 |
| **项目上下文** | `unity_get_project_context` | AI 代理项目文档注入 |
#### 高级工具分类(通过 `unity_advanced_tool` 访问)
动画、Prefab 模式、物理、光照、音频、地形、导航网格、粒子、UI、标签与层、输入系统、Shader Graph、VFX Graph、Amplify Shader Editor、性能分析器、帧调试器、内存分析器、ScriptableObject、约束、LOD、MPPM 多人 PlayMode、UMA Avatar 等 30+ 分类。
```bash
# 列出所有高级工具
> 使用 unity_list_advanced_tools 查看所有高级工具
# 按分类过滤
> unity_list_advanced_tools { "category": "animation" }
> unity_list_advanced_tools { "category": "terrain" }
# 执行高级工具
> unity_advanced_tool { "tool": "unity_animation_create_controller", "params": {...} }
```
`deploy.ps1` 会自动将所有工具写入 `~/.claude/settings.json` 的 `allowedTools` 白名单,**无需每次手动确认**。
---
### 防火墙放行Bridge 端口)
`deploy.ps1` 会自动创建防火墙规则,放行 TCP **78907899**Bridge Port 范围)。
手动放行:
```powershell
New-NetFirewallRule -DisplayName "Unity MCP Bridge Inbound" `
-Direction Inbound -Protocol TCP -LocalPort 7890-7899 -Action Allow -Profile Any
New-NetFirewallRule -DisplayName "Unity MCP Bridge Outbound" `
-Direction Outbound -Protocol TCP -LocalPort 7890-7899 -Action Allow -Profile Any
```
验证 Bridge 是否可用:
```powershell
# Unity Editor 打开后
Invoke-WebRequest http://127.0.0.1:7890/api/ping
# 返回 200 OK 表示 Bridge 正常
```
---
### 故障排查
**问题Unity MCP 连接失败**
```
解决:
1. 确认 Unity Editor 已打开,且 unity-mcp-plugin 已启动服务
2. 测试 Bridge: Invoke-WebRequest http://127.0.0.1:7890/api/ping
3. 确认防火墙 TCP 7890 已放行
4. 确认 MCP Server 进程正常wsl -d Ubuntu -- node ~/.mcp-servers/unity-mcp-server/src/index.js
5. 重新执行 deploy.ps1 重新克隆并注册 MCP Server
```
**问题:更新 unity-mcp-server 后连接失败**
```
解决:
wsl -d Ubuntu -- bash -c "cd ~/.mcp-servers/unity-mcp-server && git pull && npm install"
```
**问题npm install 安装超时(国内网络)**
```bash
# 方案一:在 Windows 侧开启系统代理v2rayN「设为系统代理」WSL2 自动继承
# 方案二WSL2 内配置镜像
npm config set registry https://registry.npmmirror.com
```
**问题WSL2 无法访问外网**
```
解决:
1. 确认 Windows 侧代理已开启系统代理v2rayN → 参数设置 → 勾选「允许来自局域网的连接」)
2. 确认 ~/.wslconfig 包含 networkingMode=mirrored 和 autoProxy=true
3. 重启 WSL2wsl --shutdown再重新启动
```
**问题Unity 版本兼容性**
```
请参考 unity-mcp-plugin 仓库的 README 了解所需 Unity 最低版本要求
```
---
## Godot MCP Pro 完整安装教程
> **版本**v1.14.1(付费本地包,随本仓库分发)
> **要求**Godot 4.x + Node.js 18+
> **组成**`server/`Node.js MCP 服务端)+ `addons/godot_mcp/`Godot 编辑器插件)
### 架构原理
```
Claude Code CLI / Claude Desktop / Cursor / Windsurf / VS Code
│ MCP 协议 (stdio)
Node.js MCP Server ← deploy.ps1 自动安装
(~/.mcp-servers/godot-mcp-pro/ ← WSL2 侧
%USERPROFILE%\godot-mcp-pro\ ← Windows 侧(供非 WSL 客户端)
server/build/index.js)
│ WebSocket
Godot Editor Plugin (GDScript) ← 手动复制到各 Godot 项目
(addons/godot_mcp/ ← 须在 Project Settings → Plugins 启用)
Godot 场景 / 节点 / 脚本 / 运行时测试...
```
> ⚠️ **端口说明**MCP 服务端自动扫描 **65056509** 寻找可用端口,**不要**在配置中固定 `GODOT_MCP_PORT`,让 Godot 插件自动连接所有候选端口即可。
---
### Step 1运行部署脚本自动安装服务端
```powershell
pwsh .\deploy.ps1
```
脚本会自动:
1. 将 `godot-mcp-pro-v1.14.1/server/` 同步到 `%USERPROFILE%\godot-mcp-pro\`Windows
2.`server/` 复制到 WSL2 `~/.mcp-servers/godot-mcp-pro/`
3. 在两侧执行 `npm install` 安装依赖(`build/` 目录已预编译,无需编译步骤)
4.`godot-mcp-pro` 条目写入 Claude Desktop / Cursor / Windsurf / VS Code / Claude Code CLI
---
### Step 2在 Godot 项目中安装插件(每个项目一次)
部署脚本**不会**自动安装 Godot 插件,因为它需要逐项目手动操作:
1. 打开文件管理器,进入本仓库目录:
```
claude-dev-stack\godot-mcp-pro-v1.14.1\addons\
```
2. 将 `godot_mcp/` 目录整体复制到你的 **Godot 项目根目录** 下的 `addons/` 中:
```
你的Godot项目/
└── addons/
└── godot_mcp/ ← 从 godot-mcp-pro-v1.14.1\addons\godot_mcp\ 复制过来
```
3. 在 Godot 编辑器中:**Project → Project Settings → Plugins**,找到 **Godot MCP Pro** 并勾选 **Enable**
4. 插件启用后,状态栏显示连接端口(如 `MCP listening on :6505`
---
### Step 3确认 MCP 配置
部署脚本已自动写入各客户端配置。Windows 侧确认:
```powershell
cat "$env:APPDATA\Claude\claude_desktop_config.json"
```
应包含Windows 侧条目,供 Claude Desktop / Cursor / Windsurf / VS Code
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["C:/Users/YourName/godot-mcp-pro/build/index.js"]
}
}
}
```
WSL2 内的条目(供 Claude Code CLI
```bash
claude mcp list --scope user
# 应包含: godot-mcp-pro node /root/.mcp-servers/godot-mcp-pro/build/index.js
```
---
### Step 4在 Claude Code 中使用 Godot MCP Pro
> ⚠️ **重要区分**Editor 工具(场景/节点/脚本操作)**随时可用**Runtime 工具(游戏运行时状态/截图/输入模拟)**必须先调用 `play_scene` 启动游戏**。
```bash
# 启动 Claude CodeWSL2 内)
claude
# 验证 Godot MCP 连接
> /mcp
# 应看到 godot-mcp-pro 及其工具列表
# ── 示例:场景搭建 ──
> 创建一个名为 Player 的 CharacterBody2D 节点,添加 CollisionShape2D 和 Sprite2D 子节点
# ── 示例:脚本操作 ──
> 读取 res://scripts/player.gd帮我添加跳跃逻辑
# ── 示例:运行测试 ──
> play_scene 后截图,检查 Player 是否出现在屏幕中央
# ── 示例:批量修改 ──
> 将场景中所有 Label 节点的字体大小统一改为 24
```
---
### 可用工具分类170+ 工具)
| 分类 | 代表工具 | 说明 |
|------|----------|------|
| **项目/文件系统** | `get_project_info`, `get_filesystem_tree`, `search_files` | 项目概览与文件搜索 |
| **场景** | `get_scene_tree`, `create_scene`, `open_scene`, `save_scene` | 场景生命周期 |
| **节点** | `add_node`, `update_property`, `connect_signal`, `batch_set_property` | 节点增删改 |
| **脚本** | `read_script`, `create_script`, `edit_script`, `validate_script` | GDScript 读写验证 |
| **编辑器** | `get_editor_screenshot`, `get_editor_errors`, `get_output_log` | 编辑器状态截图 |
| **3D** | `add_mesh_instance`, `setup_lighting`, `setup_camera_3d`, `setup_collision` | 3D 场景搭建 |
| **动画** | `create_animation`, `add_animation_track`, `create_animation_tree` | 动画与状态机 |
| **Audio** | `add_audio_bus`, `add_audio_player`, `get_audio_bus_layout` | 音频系统 |
| **导航** | `setup_navigation_region`, `bake_navigation_mesh` | NavMesh 导航 |
| **粒子** | `create_particles`, `apply_particle_preset` | 粒子特效 |
| **Shader** | `create_shader`, `edit_shader`, `assign_shader_material` | 着色器编写 |
| **Tilemap** | `tilemap_set_cell`, `tilemap_fill_rect` | 瓦片地图 |
| **分析** | `analyze_scene_complexity`, `detect_circular_dependencies`, `find_unused_resources` | 项目分析 |
| **运行时** | `play_scene`, `get_game_screenshot`, `simulate_key`, `run_test_scenario` | 运行时控制与测试 |
---
### 故障排查 — Godot MCP Pro
**问题:`/mcp` 中看不到 godot-mcp-pro**
```
解决:
1. 重新运行 deploy.ps1幂等可多次执行
2. 检查 claude mcp list --scope user 是否有 godot-mcp-pro 条目
3. 重启 Claude Codeexit → claude
```
**问题工具调用失败Godot 插件未响应**
```
解决:
1. 确认 Godot 编辑器已打开,且插件已在 Project Settings → Plugins 中启用
2. 确认插件状态栏显示 "MCP listening on :650X"
3. 不要设置 GODOT_MCP_PORT 环境变量;让服务端自动扫描
```
**问题WSL2 侧 npm install 超时**
```bash
# 设置国内镜像后重试
npm config set registry https://registry.npmmirror.com
cd ~/.mcp-servers/godot-mcp-pro && npm install
```
---
## 使用方式
### Claude Code CLI
```bash
# 进入 WSL2
wsl -d Ubuntu
# 启动 Claude Code
claude
# 或从 PowerShell 快捷命令(由 deploy.ps1 写入 profile
claude-wsl
```
### GitHub Copilot CLIWSL2 原生)
首次使用前,需要在 **WSL2 内单独登录 GitHub**
```bash
gh auth login
```
验证 gh-copilot 扩展已安装:
```bash
gh extension list | grep gh-copilot
```
常用命令:
```bash
# 命令建议
gh copilot suggest "写一个 bash 脚本,递归统计当前目录下最大的 20 个文件"
# 命令解释
gh copilot explain "find . -type f -name '*.log' -mtime +7 -delete"
```
如果你人在 Windows PowerShell 中,但想**强制调用 WSL2 版本**,使用部署脚本写入的快捷函数:
```powershell
gh-copilot-wsl suggest "生成一个 bash 命令,列出 Git 仓库中最近修改的 10 个文件"
gh-copilot-wsl explain "tar -czf backup.tar.gz ~/project --exclude node_modules"
```
这两个入口的职责建议固定:
- 在 Windows PowerShell 里直接输入 `gh ...`,默认走 Windows 版本
- 在 Windows PowerShell 里输入 `gh-copilot-wsl ...`,明确走 WSL2 版本
- 在 WSL2 终端里输入 `gh ...`,明确走 WSL2 版本
### RTK (Rust Token Killer)
RTK 支持多种 AI 编程助手集成,过滤噪音、压缩输出,减少 6090% token 消耗。
#### Claude Code 集成
RTK 作为 Claude Code 的 **PreToolUse hook** 运行,自动将 bash 命令重写为 `rtk` 等效命令。
`deploy.ps1` 会自动执行 `rtk init -g` 注册 hook**重启 Claude Code 后即生效**。
#### GitHub Copilot 集成
RTK 通过 `.github/copilot-instructions.md` 文件集成 GitHub CopilotVS Code / Copilot Chat
本仓库已包含此文件Copilot 读取后会自动在终端命令前添加 `rtk` 前缀。
```bash
# 生成 copilot-instructions 模板(如需在其他项目中集成)
rtk init -g --copilot
```
生成的 `.github/copilot-instructions.md` 内容示例:
```markdown
<!-- rtk-instructions v2 -->
# RTK — Token-Optimized CLI
Always prefix shell commands with `rtk`:
- rtk git status (instead of git status)
- rtk cargo test (instead of cargo test)
- rtk docker ps (instead of docker ps)
```
> 对已有 `copilot-instructions.md` 的项目,可手动追加 RTK 规则块,避免覆盖原有内容。
```bash
# 验证 hook 安装状态
rtk init --show
# 查看 token 节省统计(需先在 Claude Code 中运行一些命令)
rtk gain
rtk gain --graph # ASCII 图表
rtk gain --history # 最近命令历史
# 手动使用(无需 hook
rtk git status # 压缩 git status 输出
rtk cargo test # 只显示失败的测试
rtk grep "pattern" . # 分组搜索结果
```
### 故障排查 — gh copilot 使用 Windows 版本
**现象**:在 WSL2 中执行 `gh copilot` 报错 `exec: .../copilot.bat: not found`
**原因**VS Code GitHub Copilot Chat 扩展将其 `copilotCli` 目录加入了 Windows 系统 PATHWSL2 继承该路径后,`gh copilot` 内置命令从 PATH 找到了 Windows 的 `.bat` wrapper在 Linux 下无法执行。
```bash
# 方法一:重新运行 wsl-setup.sh已自动写入 PATH 修复)
bash /mnt/g/Works/server-deploy/claude-dev-stack/wsl-setup.sh
# 方法二:手动修复(写入 ~/.bashrc 并立即生效)
cat >> ~/.bashrc << 'EOF'
# 排除 VS Code Copilot Chat Windows wrappergh copilot 在 WSL2 中会误调用 .bat 文件)
PATH="$(echo "$PATH" | tr ':' '\n' | grep -Fv 'copilotCli' | tr '\n' ':' | sed 's/:$//')"
EOF
source ~/.bashrc
# 验证
gh copilot --version
```
---
### 故障排查 — rtk 安装失败
```bash
# 手动安装
cargo install --git https://github.com/rtk-ai/rtk
# 手动初始化 hook
rtk init -g
```
---
## 环境检测逻辑
脚本对每个组件均先检测是否已安装,**已安装则跳过**,实现幂等执行:
```
WSL2 → wsl --list 检测发行版
Node.js → node --version
Claude Code → claude --version
Rust → rustc --version
rtk → rtk --version
Unity MCP → 检测 ~/.mcp-servers/unity-mcp-server/
```
---
## 故障排查
### WSL2 安装失败
```powershell
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -NoRestart
Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -NoRestart
# 重启后
wsl --update
wsl --install -d Ubuntu
```
### Rust/cargo 安装慢
在 `.env` 或 `~/.bashrc` 中添加 RsProxy 镜像:
```bash
export RUSTUP_DIST_SERVER=https://rsproxy.cn
export RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup
```

1085
wsl-dev-stack/deploy.ps1 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,670 @@
# Changelog
All notable changes to Godot MCP Pro will be documented in this file.
---
## v1.14.1 — 2026-05-24
**Patch**`assert_node_state` regression fix
### Fixed
- **`assert_node_state` "Unknown command" regression**: The game-side handler in `mcp_game_inspector_service.gd` had been removed during the v1.7.0 refactor (`4ea3989`, 2026-03-29) that introduced `batch_add_nodes` / `watch_signals` / `setup_control`. The TypeScript server and editor-side GDScript still routed the call, but the runtime match statement no longer recognized it — so `assert_node_state` (and any `type:"assert"` step inside `run_test_scenario`) returned `Unknown command: assert_node_state` on every call from v1.7.0 through v1.14.0. Restored the handler with all 8 operators (`eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `contains`, `type_is`) and sub-property access via `get_indexed()` (e.g. `position:y`). Reported by **Z_runner [CRWN]** on Discord.
---
## v1.14.0 — 2026-05-18
**Feature / Safety** — File-conflict safety overhaul (community contribution)
This release is a coordinated overhaul of how the addon interacts with editor-owned resources. It prevents the "external change" dialog and silent in-memory/disk divergence that could occur when MCP commands wrote scenes, scripts, shaders, or resources while Godot still had them open. Contributed by **[@aallnneess](https://github.com/aallnneess)** (PR #31), with the design and patch reviewed and tested locally with GitHub Copilot using GPT-5.5 xhigh. Reported and validated against the previous v1.13.x behavior.
### Added — safety primitives in `base_command.gd`
- `guard_offline_scene_save(path)`: blocks `ResourceSaver.save(...)` to a `.tscn`/`.scn` path when that scene is currently open in the editor. Returns a structured conflict error (JSON-RPC code `-32009`) including the path, open-scenes list, and a recovery suggestion.
- `guard_text_resource_write(path, force)`: blocks writes to a script/shader file that is currently open in Godot's script editor (or, for shaders, currently loaded/cached in `ResourceLoader`). Override with `force=true`.
- `add_child_with_undo(...)` / `set_property_with_undo(...)`: register live scene mutations through `EditorUndoRedoManager` with correct `add_do_reference` / `add_undo_reference` retention for `Resource` values so they survive GC across the undo history.
- `get_open_scene_paths()` / `is_scene_path_open()` / `is_active_scene_path()` / `is_text_resource_open_in_script_editor()`: shared checks so per-command code never re-implements the same logic.
- `normalize_project_path()`: consistent comparison key for `res://`, project-relative, and absolute paths.
### Changed — scene saves go through `EditorInterface`
- `save_scene`: when the target path matches the active edited scene, calls `EditorInterface.save_scene()`; when the target differs or the active scene has no path yet, calls `EditorInterface.save_scene_as(path)`. Refuses to save an inactive open scene tab. No more silent `ResourceSaver.save` to an open path.
- `create_scene` / `create_theme` / `edit_resource`: all guarded against accidentally targeting an open scene path.
### Changed — broad cross-scene edits are opt-in
- **`cross_scene_set_property` defaults to `dry_run=true`** (breaking change). Real writes now require `dry_run=false` **and** `force=true`. The response includes a per-scene `mode` field (`dry_run` / `offline_saved` / `live_open_scene`) and a `skipped_open_scenes` list so callers can see exactly what happened.
- The active open scene is live-edited via `EditorUndoRedoManager` instead of being offline-overwritten, so changes are visible in the editor and undoable.
- Inactive open scenes are skipped and reported rather than silently overwritten.
### Changed — live scene mutations participate in UndoRedo
- `batch_add_nodes`, `batch_set_property`: routed through the shared UndoRedo helpers.
- `node_commands`: node creation, `set_anchor_preset` (computed on a duplicate Control before applying), signal connect/disconnect, group add/remove — all undoable.
- `animation_commands`: animation create/remove, track add, and keyframe edits. `_upsert_animation_key` / `_restore_animation_key` give round-trip undo for keyframe edits (including the previously-present-key replacement case) using `is_equal_approx` time matching.
- `animation_tree_commands`: AnimationTree create, state machine state/transition edits, blend tree node changes, tree parameter edits.
- `tilemap_commands`: single-cell set, rect fill, and clear capture the affected cells' old state and apply via UndoRedo. Round-trip undoable.
- `theme_commands`: color, constant, font-size, and stylebox theme overrides; `setup_control` computes target state on a duplicate Control before applying.
- `audio_commands`, `navigation_commands`, `particle_commands`: node creation and resource assignment go through UndoRedo. Navigation bake also marks the active scene unsaved.
- `shader_commands`: shader material assignment via `set_property_with_undo`.
### Fixed — open script/shader writes
- `create_script` / `edit_script`: refuse to write when the target is open in the script editor, unless `force=true` is explicitly passed. Also restricted to `.gd` / `.cs` extensions — scene and shader paths are rejected with a clear suggestion (added in `6d8d650`).
- `edit_script` now actually implements the 1-based inclusive `start_line` / `end_line` range replacement that the CLI had been advertising. The TypeScript `edit_script` schema and CLI `script edit` both expose the new parameters.
- `create_shader` / `edit_shader`: same open-file guard, with `force=true` to override.
- Shader cache refresh fixed: `_refresh_loaded_shader` uses `take_over_path()` + `emit_changed()` to update any cached/live `Shader` resource, replacing the unreliable `Shader.reload_from_file()` call. Live materials referencing the shader now pick up edits immediately.
### Fixed — `execute_editor_script` escape hatch
- Submitted code is scanned for direct file/resource write APIs (`ResourceSaver.save`, `FileAccess WRITE`, `ProjectSettings.save`, `ConfigFile.save`, `DirAccess` filesystem mutations). If present, the call is refused with a structured conflict error unless `allow_unsafe_editor_io=true` is explicitly passed. Closes the obvious workaround where an AI client could route around the per-command guards by submitting raw script.
### Changed — server schemas
- `create_script` / `edit_script` / `create_shader` / `edit_shader`: added optional `force` parameter and updated tool descriptions.
- `cross_scene_set_property`: added optional `dry_run` / `force` parameters and a description that reflects the new dry-run-by-default semantics.
- `execute_editor_script`: added optional `allow_unsafe_editor_io` parameter.
- `cli.ts`: `script create` and `script edit` accept `--force` flag.
### Migration notes
- **Breaking**: scripts/agents that previously relied on `cross_scene_set_property` writing on first call now need to add `dry_run=false force=true` to perform writes. Without those, the call returns a dry-run preview. This is intentional — silent project-wide writes were a footgun.
- **Soft-breaking**: scripts/agents that previously overwrote open files (scenes, scripts, shaders) without checking now hit a `-32009` conflict error. The fix is to either close the file in the editor first, save through the editor (for scenes), or pass `force=true` (for scripts/shaders, when you've verified no buffer holds unsaved changes).
- Existing wire-compatible calls that did *not* target open resources continue to work unchanged.
---
## v1.13.2 — 2026-05-13
**Bug Fix** — Port allocation race when multiple Claude Code sessions start at the same time
### Fixed
- **Parallel-session port collision** (Discord report by CrusherEAGLE): Two Claude Code sessions starting nearly simultaneously could both pre-check port 6505 as free, both attempt to bind, the loser would get `EADDRINUSE` and give up without retrying the next port. The session that lost the race had a server that never started, so every tool call failed for the remainder of that session with no way to recover short of restart. The fallback path in `index.ts` claimed it would "retry on first command" but no such retry existed.
- The fix replaces the racy pre-check + single bind with a proper bind-retry loop: each port in `65056509` is tried in turn, and `EADDRINUSE` triggers a fall-through to the next port. Only when the entire range is exhausted does `connect()` reject, with a clear error message and remediation hint.
- Cleaned up the misleading "will retry on first command" log line in `index.ts`.
### Tests
- New `tests/godot-connection.test.ts` covers: first-port allocation, sequential fall-through, **simultaneous parallel connects** (the exact regression scenario), range exhaustion, and `fixedPort=true` fail-fast behavior. 5 new tests, 62 total.
---
## v1.13.1 — 2026-05-12
**Bug Fix** — Silent disconnect / dead-connection recovery
### Fixed
- **Heartbeat now actually detects dead connections** (Discord report by CrusherEAGLE): The `ping`/`pong` heartbeat was being sent every 10s but neither side tracked whether responses were arriving, so a half-open TCP connection (common on Windows after sleep/wake, VPN toggle, or a brief editor hang) left both sides holding a dead socket. `isConnected()` continued to return `true`, every command timed out at 30s, and the only way back was to restart Claude Code **and** the Godot editor. Fixed on both sides:
- **Server**: tracks the last `pong` timestamp; if 30s passes with no pong, forcibly destroys the socket (`terminate()`, vs `close()` which waits for a FIN ack that never comes on a dead link). Pending requests are rejected immediately rather than hanging for 30s.
- **Editor**: sends its own `ping` every 5s, tracks per-port inactivity, and after 30s of inbound silence force-closes the peer so the existing 3s reconnect cycle takes over.
- **OS-level TCP keepalive** enabled on the server socket (5s initial delay), surfacing half-open links faster than Windows' ~2-hour default.
- **Status panel surfaces stale state**: New yellow ⚠ indicator when a port is reconnecting from a stale state, plus per-port idle time (seconds since last received message) in the Clients tab. No more "looks fine while everything is broken" UI.
### Notes
- Recovery is automatic within ~30s after the connection dies. Watch the Output panel for `[MCP] Port NNNN silent for X.Xs — forcing reconnect` if you want to see it happen.
- No API or tool changes — same 172 tools, same behavior in the healthy path.
---
## v1.13.0 — 2026-05-05
**Bug Fixes & Polish** — Mouse motion dispatch, setup config, site pricing
### Fixed
- **`simulate_mouse_move` honors explicit `unhandled: false`** (#24, #25): Drag motions (`button_mask > 0`) auto-promote to `push_input` so camera-pan use cases bypass GUI consumption. But callers writing UI drag-and-drop tests need events to reach the GUI dispatcher (so `_get_drag_data` / `_drop_data` fire). Now: if the caller explicitly passes `unhandled: false`, that wins; auto-promotion only happens when `unhandled` was omitted. Default behavior preserved.
- **v1.12.0 build blocker**: Restored missing `mcp/server/src/utils/load-instructions.ts` referenced by `index.ts` since v1.12.0. Fresh clones of v1.12.0 failed `npm run build` with `Cannot find module './utils/load-instructions.js'`. v1.13.0 now builds clean.
### Changed
- **`setup` no longer pins `GODOT_MCP_PORT`** (#27): Generated MCP client config (Claude Desktop, Cursor, etc.) omits the `GODOT_MCP_PORT` env var so the server can auto-scan ports `65056509`. Pinning a fixed port caused silent failures when a stale process held the port. Users who need a fixed port can still set it manually.
- **Site JSON-LD price → $15** (#26): Structured data on the landing page now reflects the current price.
### Improved
- **README clarity** (issue #7 follow-up): More prominent note that the public repo ships the addon only — the MCP server is distributed via Buy Me a Coffee / itch.io.
- **`build-release.sh` portability**: Falls back to system `zip` and `python3` when 7-Zip is not on PATH.
---
## v1.12.0 — 2026-04-19
**Feature** — Android Remote Deploy · **Bug Fix** — Runtime IPC on custom user dirs
### Added
- **Android Remote Deploy** (3 tools, Full mode only — #20):
- `list_android_devices` — wraps `adb devices -l`, returns serial/state/model/product. Resolves adb from Editor Settings > Export > Android > Adb, falling back to `adb` on PATH.
- `get_android_preset_info` — reads metadata (package name, export path, runnable) from an Android preset in `export_presets.cfg`.
- `deploy_to_android` — one-shot pipeline: Godot CLI export → `adb install -r``adb shell monkey` launch. Options: `preset_name`, `device_serial`, `debug`, `launch`, `skip_export`. Synchronous; export step can take tens of seconds.
- Full mode tool count: **169 → 172**. LITE / 3D / MINIMAL modes are unchanged.
### Fixed
- **`get_game_user_dir()`** (`commands/base_command.gd`) — #21: Runtime IPC commands (`get_game_scene_tree`, `get_game_node_properties`, `simulate_*`, etc.) failed with `Could not create game request file` when the project used `application/config/use_custom_user_dir=true`, or when `application/config/name` contained characters illegal on the host OS (e.g. `:` on Windows). Editor and game now resolve to the same dir: early-return `OS.get_user_data_dir()` for custom user dirs, and sanitize `config/name` via `xml_unescape().validate_filename().replace(".", "_")` — matching Godot's own logic in `ProjectSettings::_init`. Thanks @asim9834 for the detailed repro + patch.
---
## v1.11.0 — 2026-04-15
**Feature** — New `--3d` mode for 100-tool-limit clients
### Added
- **`--3d` mode**: Registers exactly 100 tools — the 81 core LITE tools plus Physics (6), AnimationTree (8), and Navigation (5). Designed for clients with a 100-tool cap (e.g. Google Antigravity with Claude Code proxy) that need full 3D game development capabilities. Usage: `node build/index.js --3d`
### Improved
- **Troubleshooting docs**: Clarified port conflict advice in INSTALL.md — recommends letting the server auto-scan ports 65056509 instead of setting a fixed `GODOT_MCP_PORT`, which can cause silent failures with stale processes
### Mode comparison
| Flag | Tools | Use case |
|------|-------|----------|
| *(none)* | 169 | Full mode — all tools |
| `--3d` | 100 | 3D game dev under 100-tool limit |
| `--lite` | 81 | Tight tool limits (Cursor, etc.) |
| `--minimal` | 35 | Ultra-tight limits (local LLMs) |
---
## v1.10.3 — 2026-04-11
**Bug Fixes** — Autoload preservation, Windows build, port conflict warning
### Fixed
- **Autoload deletion on `--import` / shutdown**: Plugin no longer removes pre-existing MCP autoloads from `project.godot`. Previously, `_remove_autoloads()` deleted all managed autoload keys unconditionally — even if they were project-owned. Now only autoloads injected by the current plugin session are removed. (#17)
- **Windows build failure**: Removed Unix-only `chmod -R a+x build || true` from the `build` script in `package.json`. The `chmod` and `true` commands don't exist on Windows cmd/PowerShell, causing `node build/setup.js install` to fail with "Build failed" even though TypeScript compilation succeeded. The fix is simply `"build": "tsc"` — execute permissions are not needed since the server runs via `node`. (#Discord)
### Improved
- **Port conflict warning for explicit port**: When `GODOT_MCP_PORT` is set and the port is already occupied (e.g. by a stale process), the server now logs a clear warning with remediation steps instead of silently failing to bind. (#15)
---
## v1.10.2 — 2026-04-11
**Fix** — Linux permission issue for build files
### Fixed
- **Linux permission denied**: Added `chmod -R a+x` to build process so that `build/index.js` and other compiled files have execute permission out of the box on Linux/macOS. Previously, users had to manually run `chmod -R a+x build` after installation. (Thanks to kflamsted for reporting!)
---
## v1.10.1 — 2026-04-08
**UX Improvement** — Bottom panel renamed, INSTALL.md rewritten
### Improved
- **Bottom panel renamed**: "MCP Server" → "MCP Pro" for consistency with the product name. Status label also updated.
- **INSTALL.md rewritten**: Added zip structure diagram, clear separation of addon vs server, Claude Desktop config paths, and better troubleshooting. Clarified that `configure` must be run from the Godot project directory.
---
## v1.10.0 — 2026-04-07
**New Tools & Quality Sweep** — Editor camera control, 169 tools, comprehensive audit fixes
### New Tools
- **`get_editor_camera`**: Get the 3D editor viewport camera position, rotation, and FOV. Useful for understanding the current view before taking screenshots.
- **`set_editor_camera`**: Move the 3D editor viewport camera to a specific position and orientation. Supports position, rotation, look_at target, and FOV. Use this to frame a view before screenshots to validate changes visually.
### Fixed
- **`plugin.gd` version display**: Was hardcoded to "v1.6.0" since initial release. Now dynamically reads from `plugin.cfg` — always shows the correct version.
- **Tool count inconsistency**: Was showing 162/163/167 across different files. All references now correctly say 169.
- **`node setup.js` path**: All docs and help text now correctly say `node build/setup.js`.
- **`configure` cwd issue**: INSTALL.md now clearly separates `install` (run from server/) and `configure` (run from Godot project root) to avoid `.mcp.json` being placed in the wrong directory.
- **INSTALL.md**: Fixed step numbering skip, stale tool count (49→169), port range (6505-6514).
- **README.md**: Replaced hardcoded dev paths with `/path/to/` placeholders.
### Improved
- **"v1.x" wording removed**: All pricing and marketing text now says "lifetime updates" without version scope.
- **Plugin port range**: WebSocket comment and connection range expanded to 6505-6514 (6510-6514 reserved for CLI).
- **Pre-built JS in release zip**: `build/setup.js` and `build/cli.js` work immediately after extract + `npm install`.
- **Claude Desktop support**: Confirmed working, added to configure auto-detection.
---
## v1.9.4 — 2026-04-06
**Bug Fixes** — State enum type regression, zip plugin version
### Fixed
- **`mcp_game_inspector_service.gd` State enum type error (regression)**: `var _state: State = State.IDLE` caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" in some Godot versions. Changed back to `var _state := State.IDLE` (type inference). This was originally fixed in v1.6.4 but regressed. (Thanks @kalish)
- **Release zip contained wrong plugin version**: v1.9.3 zip shipped with plugin.cfg showing v1.9.2 due to a build order issue. Fixed build pipeline to ensure public repo is synced before zip creation.
---
## v1.9.3 — 2026-04-06
**Improvement** — Pre-built JS in release zip, docs cleanup
### Improved
- **Pre-built JS files included in release zip**: `build/setup.js`, `build/cli.js`, and all other compiled files are now included. Users can run `node build/setup.js install` immediately after extracting — no need to manually run `npm run build` first.
- **CLI naming unified in docs**: Removed `godot-cli` shorthand. All docs consistently use `node build/cli.js`. Added note that server must be built before CLI use.
- **CLI help port range fixed**: `--help` output now correctly shows 6510-6514 (CLI range), not 6505-6509 (MCP server range).
---
## v1.9.2 — 2026-04-06
**New Features** — Setup CLI, code-to-inspector workflow, CLI click fix
### New Features
- **Setup CLI (`setup.js`)**: One-command server setup and management. Commands: `install` (npm install + build), `check-update` (GitHub release check with semver comparison), `configure` (auto-detect AI client and generate .mcp.json), `doctor` (environment diagnostics).
- **Code-to-inspector migration workflow**: New guideline in AGENTS.md and skills.md instructing AI to prefer `update_property` over hardcoded GDScript for visual properties (colors, sizes, theme overrides). Includes step-by-step migration pattern.
### Fixed
- **CLI `input click --button` mapping**: The CLI sent string values ("left", "right", "middle") but the plugin expects numeric indices (1, 2, 3). Now correctly maps `left`→1, `right`→2, `middle`→3. (Thanks @Gogomy)
### Improved
- **INSTALL.md**: Added quick setup flow using `setup.js` for both fresh install and updates.
- **Instruction files**: All 12 client instruction files updated with new workflow patterns.
---
## v1.9.1 — 2026-04-05
**Bug Fix** — GODOT_MCP_PORT env var now respected + Cursor Full mode
### Fixed
- **`GODOT_MCP_PORT` env var ignored**: The server always scanned ports 6505-6509 for the first free port, ignoring the explicitly configured port. Now when `GODOT_MCP_PORT` is set, the server uses that port directly without scanning. (Fixes #13)
### Changed
- **Cursor moved to Full mode**: Cursor removed its 40-tool limit with Dynamic Context Discovery — all 167 tools now work in Full mode. (Thanks to @CrossBread for PR #14)
---
## v1.9.0 — 2026-04-05
**Universal Compatibility** — Minimal mode, CLI tool, and test suite
### New Features
- **Minimal mode (`--minimal`)**: Registers only 35 essential tools for clients with tight tool limits (Cursor ~40, OpenCode, local LLMs with small context windows). Covers project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection.
- **CLI tool (`godot-cli`)**: Command-line interface for controlling Godot directly from a terminal. LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront — zero context overhead, works with any client that has bash/terminal access. 7 command groups: project, scene, node, script, editor, input, runtime.
- **Test suite**: Added vitest with 47 unit tests covering tool-filter, error utilities, zod coercion, and CLI help/error handling.
### Improved
- **Client compatibility guide**: README and landing page now include a compatibility matrix for 12+ MCP clients with recommended mode for each (Full/Lite/Minimal/CLI).
- **Landing page**: Added "Choose Your Mode" setup step, CLI documentation, and new FAQ entry for tool count limits.
- **`print_verbose` for connect/disconnect**: WebSocket connect/disconnect messages in the Godot plugin now use `print_verbose()` instead of `print()`, eliminating terminal spam during normal operation.
- **Per-client instruction files**: `instructions/` folder with ready-to-copy instruction files for 12 AI clients (Claude Code, Cursor, Cline, Windsurf, Gemini CLI, Codex CLI, OpenCode, Roo Code, JetBrains/Junie, Amazon Q, Continue, Augment). Includes CLI usage documentation.
---
## v1.8.1 — 2026-04-04
**Bug Fix**@export node reference support in update_property
### Fixed
- **`update_property` @export node references**: Setting `@export var` node references (e.g. `@export var hud: HUD`) via `update_property` now correctly resolves string paths to actual node references. Previously, `typeof(old_value)` returned `TYPE_NIL` for unset exports and `TYPE_OBJECT` for set ones, neither of which resolved the path string to a node. The fix checks `PROPERTY_HINT_NODE_TYPE` from the property's metadata to detect node reference exports and resolve accordingly. (Fixes #12)
---
## v1.8.0 — 2026-04-02
**New Features** — HTTP transport, screenshot file saving, custom class support
### New Features
- **Streamable HTTP transport**: New `--http` and `--http-port` flags for MCP clients that need HTTP instead of stdio. Starts an HTTP server at `http://127.0.0.1:8001/mcp` (default port).
- **Screenshot `save_path` option**: `get_editor_screenshot` and `get_game_screenshot` now accept an optional `save_path` parameter (e.g. `res://screenshot.png`) to save directly to disk instead of returning base64, avoiding MCP response cache bloat.
- **`add_node` custom class support**: `add_node` now resolves script-defined classes (`class_name`) in addition to built-in ClassDB types via `ProjectSettings.get_global_class_list()`.
### Improved
- **INSTALL.md**: Added "Updating to a New Version" section with step-by-step upgrade instructions.
---
## v1.7.2 — 2026-03-31
**Bug Fixes & Improvements** — execute_game_script robustness + auto-dismiss control
### Fixed
- **`execute_game_script` mixed indentation error**: User code with space indentation was prepended with tabs, causing "Mixed use of tabs and spaces" parse errors. Now auto-detects indent width and normalizes all leading spaces to tabs before wrapping.
- **`execute_game_script` standalone lambda error**: Top-level `func` definitions in user code were nested inside the wrapper's `run()` function, triggering "Standalone lambdas cannot be accessed" parse errors. Now extracts top-level functions to class level.
- **`command_router` crash on missing config section**: `_load_tool_config()` called `get_section_keys("disabled_tools")` without checking if the section exists, causing "Cannot get keys from nonexistent section" errors on fresh installs.
### Changed
- **Auto-dismiss dialogs now opt-in**: Previously auto-dismissed blocking editor dialogs whenever an MCP client was connected. Now disabled by default — AI must explicitly enable via the new `set_auto_dismiss` tool before operations that trigger reload/save dialogs.
### New Tools
- **`set_auto_dismiss`**: Enable or disable automatic dismissal of blocking editor dialogs (e.g., "Reload from disk?", "Save changes?"). Use before external file modifications, disable when done.
---
## v1.7.1 — 2026-03-30
**Bug Fixes** — Scene transition crash fix and deprecated API cleanup
### Fixed
- **`click_button_by_text` crash on scene transition**: Clicking a button that triggers a scene change (e.g., navigating from main menu to options) caused "Cannot get path of node as it is not in a scene tree" errors. Now caches button info before emitting the pressed signal and guards with `is_instance_valid()` / `is_inside_tree()` after the click.
- **Deprecated `push_unhandled_input()` warning**: Replaced with `push_input()` in `mcp_input_service.gd` per Godot 4.x API updates.
---
## v1.7.0 — 2026-03-29
**New Tools & Multi-Client Support** — 3 new tools for faster scene building, runtime signal debugging, and UI layout + instructions for non-Claude AI clients
### New Tools
- **`batch_add_nodes`**: Add multiple nodes in a single call. Nodes are processed in order so earlier nodes can be referenced as parents — build entire node trees in one shot instead of calling `add_node` repeatedly.
- **`watch_signals`**: Monitor signal emissions on specified nodes in the running game for a set duration. Returns a timestamped log of every signal fired with arguments — great for debugging event flow and verifying signal connections.
- **`setup_control`**: Configure a Control/Container node's layout in one call: anchor preset, min size, size flags, margins (MarginContainer), separation (VBox/HBoxContainer), and grow direction. Replaces 5+ `update_property` calls.
### New
- **`AGENTS.md` template**: Custom instructions for non-Claude AI clients (OpenAI Codex, opencode/ollama, Cursor, etc.). Includes editor vs runtime tool categorization, workflow patterns, formatting rules, and common pitfalls. Included in release zip.
---
## v1.6.5 — 2026-03-27
**assert_node_state Fix** — Game-side handler was missing, causing "Unknown command" error
### Fixed
- **`assert_node_state` missing game-side handler**: The command was registered in the TypeScript server and editor-side GDScript, but `mcp_game_inspector_service.gd` had no handler — returning "Unknown command" at runtime. This also broke node assertions within `run_test_scenario`. All 8 operators (eq, neq, gt, lt, gte, lte, contains, type_is) now work correctly.
- **Sub-property access in assertions**: Properties like `position:y` now use `get_indexed()` instead of `get()`, enabling assertions on vector components and nested properties.
---
## v1.6.4 — 2026-03-25
**Enum Type Fix** — Fixes script error on play in certain Godot versions
### Fixed
- **`mcp_game_inspector_service.gd` State enum type error**: Explicit `State` type annotation on `_state` variable caused "Cannot assign a value of type mcp_game_inspector_service.gd.State to variable with specified type State" errors in some Godot versions. Changed to type inference (`:=`) which resolves the mismatch.
---
## v1.6.3 — 2026-03-24
**Camera Pan Fix** — Mouse drag events now bypass GUI layer to reach `_unhandled_input()`
### Fixed
- **Mouse drag not reaching `_unhandled_input()`**: `simulate_mouse_move` with `button_mask` (drag simulation) was consumed by GUI Controls (`mouse_filter=STOP`) before reaching `_unhandled_input()`. Camera pan, drag-to-select, and other drag-based mechanics that rely on `_unhandled_input()` now work correctly. Events with `button_mask > 0` automatically use `push_unhandled_input()` to bypass the GUI layer.
### New
- **`simulate_mouse_move` `unhandled` parameter**: Optional `unhandled` flag to force any mouse motion event to bypass GUI and go directly to `_unhandled_input()`. Auto-enabled when `button_mask > 0`.
- **`simulate_sequence` `unhandled` support**: Sequence `mouse_motion` events also support the `unhandled` flag.
---
## v1.6.2 — 2026-03-24
**Animation Easing & Mouse Drag Simulation** — Community-requested fixes
### New
- **`set_animation_keyframe` easing parameter**: Optional `easing` param (default 1.0) to control keyframe transition curves. Values: 1.0=linear, <1.0=ease-in, >1.0=ease-out, negative=in-out variants.
- **`get_animation_info` easing field**: Each keyframe now returns its `easing` value.
- **`simulate_mouse_move` button_mask**: New `button_mask` parameter (1=left, 2=right, 4=middle) enables drag simulation. Required for games that check `InputEventMouseMotion.button_mask` (e.g. camera pan with mouse drag).
- **`simulate_sequence` button_mask**: Sequence events also support `button_mask` for drag operations.
### Fixed
- **Mouse sequence events**: `simulate_sequence` now correctly handles flat key format (`relative_x`, `relative_y`, `x`, `y`) in addition to nested format. Previously, mouse motion events in sequences had `relative=(0,0)` because the flat-to-nested conversion was missing.
---
## v1.6.1 — 2026-03-21
**Permission Presets** — Auto-approve tool permissions for Claude Code
### New
- **`settings.local.json`** (conservative): Pre-configured permission file that auto-approves 152 of 163 tools. Destructive tools (`delete_node`, `delete_scene`, `execute_editor_script`, etc.) still require manual approval.
- **`settings.local.permissive.json`**: Allows all 163 tools and all Bash commands, with an explicit deny list for dangerous shell commands (`rm -rf`, `git push --force`, `git reset --hard`, etc.) and destructive MCP tools.
- Copy either file to `~/.claude/settings.local.json` to skip per-tool permission prompts.
---
## v1.6.0 — 2026-03-21
**Enhanced Editor Panel** — Activity log with response details, client monitor, and tool management
### New
- **Activity tab**: Full command log showing method name, status, port, and timestamp. Toggle "Show Response Details" to inspect the JSON responses sent back to AI clients. Clear button to reset the log.
- **Clients tab**: Real-time view of all 5 WebSocket ports (6505-6509) with connection status and elapsed time since connection.
- **Tools tab**: Searchable list of all 163 tools with individual enable/disable checkboxes. Bulk "Enable All" / "Disable All" buttons. Disabled tools are persisted across sessions (`user://mcp_tool_config.cfg`) and return a clear error message to AI clients.
### Changed
- Status panel rebuilt with TabContainer (Activity / Clients / Tools)
- WebSocket server now emits `command_completed` signal with full response data and source port
- Connection time tracking per port for uptime display
---
## v1.5.3 — 2026-03-15
**New tool**`record_frames` for long-running debug observation
### New
- **`record_frames`**: Capture up to 600 screenshots saved as PNG files to `user://mcp_recorded_frames/`. Unlike `capture_frames` (which returns base64 images directly, max 30), this tool saves to disk and returns file paths — ideal for long-running debug sessions without flooding the AI context with image data. Supports optional `node_data` tracking for per-frame property snapshots (position, velocity, etc.).
---
## v1.5.2 — 2026-03-13
**Bugfix** — Screenshot capture now works when the SceneTree is paused
### Fixed
- **`mcp_screenshot_service.gd`**: Added `process_mode = Node.PROCESS_MODE_ALWAYS` so the file-polling loop in `_process()` keeps running during pause. The other two autoloads (`mcp_input_service.gd`, `mcp_game_inspector_service.gd`) already had this — screenshot service was the only one missing it.
- **`mcp_screenshot_service.gd`**: Replaced `await get_tree().process_frame` with `await get_tree().create_timer(0.05).timeout``process_frame` never fires when the tree is paused, but `create_timer()` with default `process_always=true` does.
Thanks to **mrkielbasa** for reporting this bug!
---
## v1.5.1 — 2026-03-08
**Patch release** — AI Skills file for better out-of-the-box experience
### New
- **`skills.md`**: Added `addons/godot_mcp/skills.md` — a comprehensive guide for AI assistants covering all 162 tools, 10 practical workflows, best practices, and common pitfalls. Users can copy this to `.claude/skills.md` in their project root so Claude Code knows how to use the MCP tools effectively from the start.
- **README**: Added setup step for copying `skills.md` to `.claude/skills.md`.
---
## v1.5.0 — 2026-03-04
**Feature** — Lite mode for MCP clients with tool count limits
### New Features
- **Lite mode (`--lite`)**: Launch with `--lite` flag to register only 76 core tools instead of 162. Designed for MCP clients with tool count limits (Windsurf: 100, Cursor: ~40, Antigravity: 100).
- Core categories (always loaded): project, scene, node, script, editor, input, runtime, input_map
- Extended categories (Full mode only): animation, animation_tree, audio, batch, export, navigation, particle, physics, profiling, resource, scene_3d, shader, test, theme, tilemap, analysis
- Usage: Add `"--lite"` to args in your MCP config
---
## v1.4.5 — 2026-03-04
**Patch release** — Godot 4.3 compatibility fix
### Bug Fixes
- **Godot 4.3 compatibility**: Fixed `scene_3d_commands.gd` parse error caused by `Environment.TONE_MAPPER_AGX` enum (added in Godot 4.4). Now uses integer value for backward compatibility. This was a blocking error that prevented the entire plugin from loading on Godot 4.3.
---
## v1.4.4 — 2026-03-04
**Patch release** — Revert Output panel filter expansion
### Bug Fixes
- **`get_editor_errors`**: Removed `W `, `WARN`, `GDScript` Output panel filters added in v1.4.3 — these patterns don't actually appear in Godot's Output panel (`push_warning` uses `WARNING:` prefix) and caused false positives on normal text.
---
## v1.4.3 — 2026-03-04
**Patch release** — Comprehensive error/warning detection
### Improvements
- **`get_editor_errors`**: Now reads runtime errors from the debugger Errors tab (ScriptEditorDebugger), returned with `DEBUGGER:` prefix including stack traces. Previously only static analysis and Output panel errors were captured.
### Bug Fixes
- **`get_editor_errors`**: Fixed debugger Errors tab not being found because the tab name includes a count suffix (e.g. "Errors (1)") — now uses prefix matching.
---
## v1.4.2 — 2026-03-04
**Patch release** — Improved error detection in script editor
### Improvements
- **`get_editor_errors`**: Now reads GDScript analyzer errors and warnings from the script editor's error/warning panels (VSplitContainer RichTextLabels), in addition to the Output panel and CodeEdit line highlights. Catches static analysis messages like type mismatches and autoload name conflicts that were previously missed.
---
## v1.4.1 — 2026-03-02
**Patch release** — Bug fixes found during comprehensive tool audit
### Bug Fixes
- **`replay_recording`**: Fixed false crash recovery error (`_pending_command` flag not cleared for async replay loop)
- **`wait_for_node`**: Fixed false crash recovery error when polling for node appearance
- **`apply_particle_preset`**: Fixed editor crash in `gl_compatibility` renderer caused by immediate `GradientTexture1D` assignment — now uses `set_deferred` and reduced texture width
---
## v1.4.0 — 2026-03-01
**162 tools** across 23 categories (+15 new tools)
### New Tools
- **`move_to`** — Autopilot: automatically walk a character to target coordinates using pathfinding
- **`navigate_to`** — High-level navigation command for AI-driven movement
- **`find_nearby_nodes`** — Find nodes within a radius of a given position
- **`get_node_groups`** / **`set_node_groups`** — Read and write node group memberships
- **`find_nodes_in_group`** — Query all nodes belonging to a specific group
- **`get_output_log`** — Retrieve Godot's Output panel contents
- **`get_input_actions`** / **`set_input_action`** — Read and configure Input Map actions
- **`search_in_files`** — Full-text search across project files
- **`validate_script`** — Check GDScript for errors without running
- **`get_resource_preview`** — Get thumbnail previews of resources
- **`get_scene_exports`** — List exported variables in a scene's root script
- **`add_autoload`** / **`remove_autoload`** — Manage autoload singletons
### Bug Fixes & Improvements
- **Crash recovery**: `capture_frames` no longer triggers false crash recovery (`_pending_command` flag fix)
- **`capture_frames` node_data**: Optional per-frame property snapshots via `node_data` parameter
- **Debugger auto-continue**: Automatically presses Continue when runtime errors pause the debugger
- **`simulate_key` duration**: Now accepts fractional seconds (e.g., 0.3s) for precise movement
- **Command router fix**: All 8 command classes now properly registered (~47 tools were previously unreachable)
---
## v1.3.1 — 2026-02-27
**Patch release**
### Bug Fixes
- **`get_editor_errors`**: Now reads from Output panel and CodeEdit error gutter (previously returned empty results)
- **Tonemap enum**: Fixed environment tonemap mode enum name mapping
---
## v1.3.0 — 2026-02-26
**147 tools** across 23 categories (+63 new tools)
### New Tool Categories
#### AnimationTree & State Machine (8 tools)
- `create_animation_tree`, `get_animation_tree_structure`, `set_tree_parameter`
- `add_state_machine_state`, `remove_state_machine_state`
- `add_state_machine_transition`, `remove_state_machine_transition`
- `set_blend_tree_node`
#### Physics & Collision (6 tools)
- `setup_collision`, `setup_physics_body`, `get_collision_info`
- `get_physics_layers`, `set_physics_layers`, `add_raycast`
#### 3D Scene (6 tools)
- `add_mesh_instance`, `setup_lighting`, `set_material_3d`
- `setup_environment`, `setup_camera_3d`, `add_gridmap`
#### Particles (5 tools)
- `create_particles`, `set_particle_material`, `set_particle_color_gradient`
- `get_particle_info`, `apply_particle_preset` (8 built-in presets: fire, smoke, sparks, rain, snow, explosion, magic, dust)
#### Navigation (5 tools)
- `setup_navigation_region`, `bake_navigation_mesh`, `setup_navigation_agent`
- `set_navigation_layers`, `get_navigation_info`
#### Audio (6 tools)
- `get_audio_bus_layout`, `add_audio_bus`, `set_audio_bus`
- `add_audio_bus_effect`, `add_audio_player`, `get_audio_info`
#### Testing & QA (5 tools)
- `run_test_scenario`, `assert_node_state`, `assert_screen_text`
- `run_stress_test`, `get_test_report`
#### Project Analysis (6 tools)
- `find_unused_resources`, `analyze_signal_flow`, `analyze_scene_complexity`
- `detect_circular_dependencies`, `get_scene_dependencies`, `get_project_statistics`
### Expanded: Runtime Analysis
- `find_ui_elements`, `click_button_by_text`, `wait_for_node`
- Runtime tools expanded from 4 to 15 tools
### Other Additions
- `add_resource`, `create_resource`, `edit_resource`, `read_resource` — Resource management tools
---
## v1.2.0 — 2026-02-24
**84 tools** across 14 categories (+34 new tools)
### New Tool Categories
#### Animation (6 tools)
- `list_animations`, `create_animation`, `add_animation_track`
- `set_animation_keyframe`, `get_animation_info`, `remove_animation`
#### TileMap (6 tools)
- `tilemap_set_cell`, `tilemap_fill_rect`, `tilemap_get_cell`
- `tilemap_clear`, `tilemap_get_info`, `tilemap_get_used_cells`
#### Theme & UI (6 tools)
- `create_theme`, `set_theme_color`, `set_theme_constant`
- `set_theme_font_size`, `set_theme_stylebox`, `get_theme_info`
#### Profiling (2 tools)
- `get_performance_monitors`, `get_editor_performance`
#### Batch Operations & Refactoring (5 tools)
- `find_nodes_by_type`, `find_signal_connections`, `batch_set_property`
- `find_node_references`, `get_scene_dependencies`
#### Shader (6 tools)
- `create_shader`, `read_shader`, `edit_shader`
- `assign_shader_material`, `set_shader_param`, `get_shader_params`
#### Export (3 tools)
- `list_export_presets`, `export_project`, `get_export_info`
### Bug Fixes
- Fixed game IPC connection when project name changes
- Added `set_project_setting` tool for safe project.godot modifications via EditorSettings API
- Fixed script reload behavior
---
## v1.1.0 — 2026-02-23
**49 tools** across 8 categories (+16 new tools)
### New Tool Categories
#### Input Simulation (4 tools)
- `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_sequence`
#### Runtime Analysis (4 tools)
- `play_scene`, `stop_scene`, `get_game_scene_tree`, `get_game_screenshot`
- `execute_game_script`, `get_game_node_properties`, `set_game_node_property`
- `monitor_properties`, `capture_frames`
### Other
- Added `build-release.sh` for reproducible release packaging
- `start_recording` / `stop_recording` / `replay_recording` for input recording
---
## v1.0.0 — 2026-02-22
**~33 tools** across 6 categories — Initial release
### Tool Categories
- **Scene Management**: `create_scene`, `open_scene`, `save_scene`, `get_scene_tree`, `delete_scene`, `get_scene_file_content`, `add_scene_instance`
- **Node Operations**: `add_node`, `delete_node`, `rename_node`, `move_node`, `duplicate_node`, `update_property`, `get_node_properties`, `batch_get_properties`, `connect_signal`, `disconnect_signal`, `get_signals`
- **Script**: `create_script`, `read_script`, `edit_script`, `attach_script`, `list_scripts`, `find_nodes_by_script`, `find_script_references`, `get_open_scripts`
- **Editor**: `get_editor_screenshot`, `get_editor_errors`, `clear_output`, `execute_editor_script`, `reload_plugin`
- **Project**: `get_project_info`, `get_project_settings`, `get_filesystem_tree`, `search_files`
- **UI**: Anchor presets (`set_anchor_preset`)
### Architecture
- WebSocket-based communication between Godot editor plugin and MCP TypeScript server
- Supports Claude Code, Cursor, Windsurf, and any MCP-compatible AI coding tool
- Screenshot capture from both editor and game viewports

View File

@@ -0,0 +1,121 @@
# Godot MCP Pro - Installation Guide
## What's in the zip
```
godot-mcp-pro/
├── addons/godot_mcp/ ← Godot plugin (copy into your Godot project)
├── server/ ← MCP server (keep anywhere, runs alongside Godot)
├── instructions/ ← AI client instruction files (optional)
├── INSTALL.md ← This file
├── README.md
├── CHANGELOG.md
└── ...
```
The **addon** and **server** are two separate pieces:
- **Addon** → goes inside your Godot project
- **Server** → stays wherever you extracted it (does NOT go inside Godot)
## Step 1: Install the Godot Plugin
Copy the `addons/godot_mcp/` folder from the zip into your Godot project's `addons/` directory.
Enable the plugin in Godot:
**Project → Project Settings → Plugins → Godot MCP Pro → Enable**
You should see "MCP Pro" in the bottom panel with a green connection dot.
> **Note**: You do NOT need to download anything from the Godot Asset Library. The paid zip includes everything.
## Step 2: Build the MCP Server
The server requires **Node.js 18+**. Check with `node --version`.
Open a terminal and run from the `server/` directory inside the extracted zip:
```bash
cd /path/to/extracted/server
node build/setup.js install
```
This runs `npm install` (downloads dependencies) and `npm run build` (compiles TypeScript).
You can verify everything is working with:
```bash
node build/setup.js doctor
```
## Step 3: Configure Your AI Client
Run this from your **Godot project** directory (not the server directory):
```bash
cd /path/to/your/godot-project
node /path/to/extracted/server/build/setup.js configure
```
This auto-detects your AI client and creates a `.mcp.json` file in your project.
### Manual Configuration
If you prefer to configure manually, add this to your project's `.mcp.json`:
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["/path/to/extracted/server/build/index.js"]
}
}
}
```
Replace `/path/to/extracted/` with the actual path where you extracted the zip.
### Claude Desktop
For Claude Desktop, add the same config to:
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
## Step 4: Use It
1. Open your Godot project with the plugin enabled
2. Start your AI client (Claude Code, Cursor, Cline, etc.) in your project directory
3. Ask the AI to interact with your Godot editor
The MCP Pro bottom panel in Godot shows connection status. A green dot means connected.
## Updating to a New Version
Check for updates:
```bash
node /path/to/server/build/setup.js check-update
```
To update:
1. Close Godot
2. Replace `addons/godot_mcp/` in your Godot project with the new version from the zip
3. Replace the `server/` folder with the new one, then rebuild:
```bash
cd /path/to/new/server
node build/setup.js install
```
4. Reopen Godot
Your `.mcp.json` configuration stays the same — no need to reconfigure.
## Troubleshooting
- **Plugin not connecting**: Make sure the MCP server is running (your AI client starts it automatically via `.mcp.json`)
- **"Godot editor is not connected" error**: This is usually caused by a stale `node.exe` process from a previous session holding the port. Open Task Manager, kill all `node.exe` processes, then restart your AI client.
- **Port conflict / `GODOT_MCP_PORT`**: Avoid setting a fixed `GODOT_MCP_PORT` in your config — the server auto-scans ports 65056509 and Godot connects to all of them automatically. A fixed port can cause silent failures if a stale process is already using it.
- **Bottom panel shows "Waiting for connection"**: Start your AI client — it launches the MCP server which connects to Godot
- **Need help?**: Contact abyo.software@gmail.com or join [Discord](https://discord.gg/zJ2u5zNUBZ)
## Documentation
- Landing page & tool reference: https://godot-mcp.abyo.net
- Full tool list: `README.md`

View File

@@ -0,0 +1,42 @@
Godot MCP Pro - Proprietary License
Copyright (c) 2026 Godot MCP Pro. All rights reserved.
This software and associated documentation files (the "Software") are the
proprietary property of the copyright holder.
1. GRANT OF LICENSE
Upon purchase, you are granted a non-exclusive, non-transferable,
perpetual license to:
- Use the Software for personal and commercial game development projects
- Install the Software on any number of your own machines
- Use the Software in any number of your own projects
2. RESTRICTIONS
You may NOT:
- Redistribute, sublicense, sell, or share the Software or any portion
of its source code
- Modify and redistribute the Software as a competing product
- Remove or alter any copyright notices or license information
- Share your license key or purchased files with others
3. UPDATES
Your purchase includes lifetime access to updates for the version you
purchased (1.x). Major version upgrades (2.x, 3.x) may require a
separate purchase or upgrade fee.
4. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
5. TERMINATION
This license is automatically terminated if you violate any of these
restrictions. Upon termination, you must destroy all copies of the
Software in your possession.
For questions about licensing, contact: abyo.software@gmail.com

View File

@@ -0,0 +1,496 @@
# Godot MCP Pro
Premium MCP (Model Context Protocol) server for AI-powered Godot game development. Connects AI assistants like Claude directly to your Godot editor with **172 powerful tools**.
## Architecture
```
AI Assistant ←—stdio/MCP—→ Node.js Server ←—WebSocket:6505—→ Godot Editor Plugin
```
- **Real-time**: WebSocket connection means instant feedback, no file polling
- **Editor Integration**: Full access to Godot's editor API, UndoRedo system, and scene tree
- **JSON-RPC 2.0**: Standard protocol with proper error codes and suggestions
## What's in this repo
> ⚠️ **This public repo only contains the free Godot addon/plugin.** The MCP server (Node.js, required to connect AI assistants) is distributed as part of the paid package — **one-time purchase**, lifetime updates:
>
> - **Buy Me a Coffee**: <https://buymeacoffee.com/y1uda/extras>
> - **itch.io**: <https://y1uda.itch.io/godot-mcp-pro>
>
> The paid zip includes the addon, the `server/` directory with pre-built JavaScript, `INSTALL.md`, and AI-client instructions. If you cloned this repo and don't see a `server/` folder, **that's expected** — grab the full package from one of the links above.
## Quick Start
### 1. Install the Godot Plugin
Copy the `addons/godot_mcp/` folder into your Godot project's `addons/` directory.
Enable the plugin: **Project → Project Settings → Plugins → Godot MCP Pro → Enable**
### 2. Install the MCP Server
> The `server/` directory is only included in the **full paid package** (see above). After downloading and extracting the zip, run:
```bash
cd server
npm install
npm run build
```
### 3. Configure Claude Code
Add to your `.mcp.json`:
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["/path/to/server/build/index.js"],
"env": {
"GODOT_MCP_PORT": "6505"
}
}
}
}
```
### 4. Choose Your Mode
Godot MCP Pro offers four modes to fit any client's tool limit:
| Mode | Tools | Best For |
|------|-------|----------|
| **Full** (default) | 172 | Claude Code, Cline, VS Code Copilot, Cursor |
| **3D** (`--3d`) | 100 | Antigravity and other 100-tool-limit clients needing 3D |
| **Lite** (`--lite`) | 81 | Windsurf, JetBrains Junie, Gemini CLI |
| **Minimal** (`--minimal`) | 35 | OpenCode, local LLMs with small context |
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["/path/to/server/build/index.js", "--lite"]
}
}
}
```
Replace `--lite` with `--minimal` for the smallest footprint.
- **Lite** includes: project, scene, node, script, editor, input, runtime, and input_map tools.
- **Minimal** includes: 35 essential tools — project info, scene management, node CRUD, script editing, editor errors, input simulation, and runtime inspection.
### 5. CLI Mode (Alternative to MCP)
For clients without MCP support, or when you want zero context overhead, use the CLI directly from a terminal/bash tool. The CLI requires the server to be built first (Step 2).
```bash
# Top-level help — shows all command groups
node /path/to/server/build/cli.js --help
# Group help — shows commands in a group
node /path/to/server/build/cli.js node --help
# Command help — shows options for a command
node /path/to/server/build/cli.js node add --help
# Execute
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player
```
Replace `/path/to/` with the actual path where you extracted the files.
The CLI connects directly to the Godot editor plugin via WebSocket. It requires:
- Godot editor running with the MCP plugin enabled
- Server built (`node build/setup.js install`)
- An available port in the 6510-6514 range
**Advantage**: LLMs discover capabilities progressively via `--help` instead of loading all tool definitions upfront. This works with any LLM client that has terminal access, regardless of tool count limits.
### 6. Client Compatibility
| Client | Recommended Mode | Notes |
|--------|-----------------|-------|
| Claude Code | Full (default) | Deferred tool loading — minimal context cost |
| VS Code Copilot | Full | Virtual Tools auto-group tools |
| OpenAI Codex CLI | Full | MCPSearch defers overflow |
| Cline | Full | No hard limit; use `enabledTools` to whitelist |
| Roo Code | Full | No hard limit |
| Windsurf | Lite | 100 tool limit |
| JetBrains Junie | Lite | 100 tool limit |
| Gemini CLI | Lite | ~100 client limit; use `excludeTools` for finer control |
| Cursor | Full | Tool limit removed (Dynamic Context Discovery) |
| OpenCode | Minimal or CLI | Models degrade past ~40 tools |
| Local LLMs (LM Studio, etc.) | Minimal or CLI | Context window is the bottleneck |
### 7. Use It
Open your Godot project with the plugin enabled, then use Claude Code to interact with the editor.
## All 172 Tools
### Project Tools (7)
| Tool | Description |
|------|-------------|
| `get_project_info` | Project metadata, version, viewport, autoloads |
| `get_filesystem_tree` | Recursive file tree with filtering |
| `search_files` | Fuzzy/glob file search |
| `get_project_settings` | Read project.godot settings |
| `set_project_setting` | Set project settings via editor API |
| `uid_to_project_path` | UID → res:// conversion |
| `project_path_to_uid` | res:// → UID conversion |
### Scene Tools (9)
| Tool | Description |
|------|-------------|
| `get_scene_tree` | Live scene tree with hierarchy |
| `get_scene_file_content` | Raw .tscn file content |
| `create_scene` | Create new scene files |
| `open_scene` | Open scene in editor |
| `delete_scene` | Delete scene file |
| `add_scene_instance` | Instance scene as child node |
| `play_scene` | Run scene (main/current/custom) |
| `stop_scene` | Stop running scene |
| `save_scene` | Save current scene to disk |
### Node Tools (14)
| Tool | Description |
|------|-------------|
| `add_node` | Add node with type and properties |
| `delete_node` | Delete node (with undo support) |
| `duplicate_node` | Duplicate node and children |
| `move_node` | Move/reparent node |
| `update_property` | Set any property (auto type parsing) |
| `get_node_properties` | Get all node properties |
| `add_resource` | Add Shape/Material/etc to node |
| `set_anchor_preset` | Set Control anchor preset |
| `rename_node` | Rename a node in the scene |
| `connect_signal` | Connect signal between nodes |
| `disconnect_signal` | Disconnect signal connection |
| `get_node_groups` | Get groups a node belongs to |
| `set_node_groups` | Set node group membership |
| `find_nodes_in_group` | Find all nodes in a group |
### Script Tools (8)
| Tool | Description |
|------|-------------|
| `list_scripts` | List all scripts with class info |
| `read_script` | Read script content |
| `create_script` | Create new script with template |
| `edit_script` | Search/replace or full edit |
| `attach_script` | Attach script to node |
| `get_open_scripts` | List scripts open in editor |
| `validate_script` | Validate GDScript syntax |
| `search_in_files` | Search content in project files |
### Editor Tools (9)
| Tool | Description |
|------|-------------|
| `get_editor_errors` | Get errors and stack traces |
| `get_editor_screenshot` | Capture editor viewport |
| `get_game_screenshot` | Capture running game |
| `execute_editor_script` | Run arbitrary GDScript in editor |
| `clear_output` | Clear output panel |
| `get_signals` | Get all signals of a node with connections |
| `reload_plugin` | Reload the MCP plugin (auto-reconnect) |
| `reload_project` | Rescan filesystem and reload scripts |
| `get_output_log` | Get output panel content |
### Input Tools (7)
| Tool | Description |
|------|-------------|
| `simulate_key` | Simulate keyboard key press/release |
| `simulate_mouse_click` | Simulate mouse click at position |
| `simulate_mouse_move` | Simulate mouse movement |
| `simulate_action` | Simulate Godot Input Action |
| `simulate_sequence` | Sequence of input events with frame delays |
| `get_input_actions` | List all input actions |
| `set_input_action` | Create/modify input action |
### Runtime Tools (19)
| Tool | Description |
|------|-------------|
| `get_game_scene_tree` | Scene tree of running game |
| `get_game_node_properties` | Node properties in running game |
| `set_game_node_property` | Set node property in running game |
| `execute_game_script` | Run GDScript in game context |
| `capture_frames` | Multi-frame screenshot capture |
| `monitor_properties` | Record property values over time |
| `start_recording` | Start input recording |
| `stop_recording` | Stop input recording |
| `replay_recording` | Replay recorded input |
| `find_nodes_by_script` | Find game nodes by script |
| `get_autoload` | Get autoload node properties |
| `batch_get_properties` | Batch get multiple node properties |
| `find_ui_elements` | Find UI elements in game |
| `click_button_by_text` | Click button by text content |
| `wait_for_node` | Wait for node to appear |
| `find_nearby_nodes` | Find nodes near position |
| `navigate_to` | Navigate to target position |
| `move_to` | Walk character to target |
### Animation Tools (6)
| Tool | Description |
|------|-------------|
| `list_animations` | List all animations in AnimationPlayer |
| `create_animation` | Create new animation |
| `add_animation_track` | Add track (value/position/rotation/method/bezier) |
| `set_animation_keyframe` | Insert keyframe into track |
| `get_animation_info` | Detailed animation info with all tracks/keys |
| `remove_animation` | Remove an animation |
### TileMap Tools (6)
| Tool | Description |
|------|-------------|
| `tilemap_set_cell` | Set a single tile cell |
| `tilemap_fill_rect` | Fill rectangular region with tiles |
| `tilemap_get_cell` | Get tile data at cell |
| `tilemap_clear` | Clear all cells |
| `tilemap_get_info` | TileMapLayer info and tile set sources |
| `tilemap_get_used_cells` | List of used cells |
### Theme & UI Tools (6)
| Tool | Description |
|------|-------------|
| `create_theme` | Create Theme resource file |
| `set_theme_color` | Set theme color override |
| `set_theme_constant` | Set theme constant override |
| `set_theme_font_size` | Set theme font size override |
| `set_theme_stylebox` | Set StyleBoxFlat override |
| `get_theme_info` | Get theme overrides info |
### Profiling Tools (2)
| Tool | Description |
|------|-------------|
| `get_performance_monitors` | All performance monitors (FPS, memory, physics, etc.) |
| `get_editor_performance` | Quick performance summary |
### Batch & Refactoring Tools (8)
| Tool | Description |
|------|-------------|
| `find_nodes_by_type` | Find all nodes of a type |
| `find_signal_connections` | Find all signal connections in scene |
| `batch_set_property` | Set property on all nodes of a type |
| `find_node_references` | Search project files for pattern |
| `get_scene_dependencies` | Get resource dependencies |
| `cross_scene_set_property` | Set property across all scenes |
| `find_script_references` | Find where script/resource is used |
| `detect_circular_dependencies` | Find circular scene dependencies |
### Shader Tools (6)
| Tool | Description |
|------|-------------|
| `create_shader` | Create shader with template |
| `read_shader` | Read shader file |
| `edit_shader` | Edit shader (replace/search-replace) |
| `assign_shader_material` | Assign ShaderMaterial to node |
| `set_shader_param` | Set shader parameter |
| `get_shader_params` | Get all shader parameters |
### Export Tools (3)
| Tool | Description |
|------|-------------|
| `list_export_presets` | List export presets |
| `export_project` | Get export command for preset |
| `get_export_info` | Export-related project info |
### Resource Tools (6)
| Tool | Description |
|------|-------------|
| `read_resource` | Read .tres resource properties |
| `edit_resource` | Edit resource properties |
| `create_resource` | Create new .tres resource |
| `get_resource_preview` | Get resource thumbnail |
| `add_autoload` | Register autoload singleton |
| `remove_autoload` | Remove autoload singleton |
### Physics Tools (6)
| Tool | Description |
|------|-------------|
| `setup_physics_body` | Configure physics body properties |
| `setup_collision` | Add collision shapes to nodes |
| `set_physics_layers` | Set collision layer/mask |
| `get_physics_layers` | Get collision layer/mask info |
| `get_collision_info` | Get collision shape details |
| `add_raycast` | Add RayCast2D/3D node |
### 3D Scene Tools (6)
| Tool | Description |
|------|-------------|
| `add_mesh_instance` | Add MeshInstance3D with primitive mesh |
| `setup_camera_3d` | Configure Camera3D properties |
| `setup_lighting` | Add/configure light nodes |
| `setup_environment` | Configure WorldEnvironment |
| `add_gridmap` | Set up GridMap node |
| `set_material_3d` | Set StandardMaterial3D properties |
### Particle Tools (5)
| Tool | Description |
|------|-------------|
| `create_particles` | Create GPUParticles2D/3D |
| `set_particle_material` | Configure ParticleProcessMaterial |
| `set_particle_color_gradient` | Set color gradient for particles |
| `apply_particle_preset` | Apply preset (fire, smoke, sparks, etc.) |
| `get_particle_info` | Get particle system details |
### Navigation Tools (6)
| Tool | Description |
|------|-------------|
| `setup_navigation_region` | Configure NavigationRegion |
| `setup_navigation_agent` | Configure NavigationAgent |
| `bake_navigation_mesh` | Bake navigation mesh |
| `set_navigation_layers` | Set navigation layers |
| `get_navigation_info` | Get navigation setup info |
### Audio Tools (6)
| Tool | Description |
|------|-------------|
| `add_audio_player` | Add AudioStreamPlayer node |
| `add_audio_bus` | Add audio bus |
| `add_audio_bus_effect` | Add effect to audio bus |
| `set_audio_bus` | Configure audio bus properties |
| `get_audio_bus_layout` | Get audio bus layout info |
| `get_audio_info` | Get audio-related node info |
### AnimationTree Tools (4)
| Tool | Description |
|------|-------------|
| `create_animation_tree` | Create AnimationTree |
| `get_animation_tree_structure` | Get tree structure |
| `set_tree_parameter` | Set AnimationTree parameter |
| `add_state_machine_state` | Add state to state machine |
### State Machine Tools (3)
| Tool | Description |
|------|-------------|
| `remove_state_machine_state` | Remove state from state machine |
| `add_state_machine_transition` | Add transition between states |
| `remove_state_machine_transition` | Remove state transition |
### Blend Tree Tools (1)
| Tool | Description |
|------|-------------|
| `set_blend_tree_node` | Configure blend tree nodes |
### Analysis & Search Tools (4)
| Tool | Description |
|------|-------------|
| `analyze_scene_complexity` | Analyze scene performance |
| `analyze_signal_flow` | Map signal connections |
| `find_unused_resources` | Find unreferenced resources |
| `get_project_statistics` | Get project-wide statistics |
### Testing & QA Tools (6)
| Tool | Description |
|------|-------------|
| `run_test_scenario` | Run automated test scenario |
| `assert_node_state` | Assert node property values |
| `assert_screen_text` | Check for text on screen |
| `compare_screenshots` | Compare two screenshots |
| `run_stress_test` | Run performance stress test |
| `get_test_report` | Get test results report |
## Key Features
- **UndoRedo Integration**: All node/property operations support Ctrl+Z
- **Smart Type Parsing**: `"Vector2(100, 200)"`, `"#ff0000"`, `"Color(1,0,0)"` auto-converted
- **Auto-Reconnect**: Exponential backoff reconnection (1s → 2s → 4s ... → 60s max)
- **Heartbeat**: 10s ping/pong keeps connection alive
- **Helpful Errors**: Error responses include suggestions for next steps
## Competitive Comparison
### Tool Count
| Category | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) | ee0pdt (free) | bradypp (free) |
|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| Project | 7 | 5 | 4 | 0 | 2 | 2 | 2 |
| Scene | 9 | 8 | 11 | 9 | 3 | 4 | 5 |
| Node | **14** | 8 | 0 | 8 | 2 | 3 | 0 |
| Script | **8** | 5 | 6 | 4 | 0 | 5 | 0 |
| Editor | **9** | 5 | 1 | 5 | 1 | 3 | 2 |
| Input | **7** | 2 | 0 | 0 | 0 | 0 | 0 |
| Runtime | **19** | 0 | 0 | 0 | 0 | 0 | 0 |
| Animation | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| TileMap | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Theme/UI | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Profiling | **2** | 0 | 0 | 0 | 0 | 0 | 0 |
| Batch/Refactor | **8** | 0 | 0 | 0 | 0 | 0 | 0 |
| Shader | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Export | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
| Resource | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Physics | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| 3D Scene | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Particle | **5** | 0 | 0 | 0 | 0 | 0 | 0 |
| Navigation | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Audio | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| AnimationTree | **4** | 0 | 0 | 0 | 0 | 0 | 0 |
| State Machine | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
| Blend Tree | **1** | 0 | 0 | 0 | 0 | 0 | 0 |
| Analysis | **4** | 0 | 0 | 0 | 0 | 0 | 0 |
| Testing/QA | **6** | 0 | 0 | 0 | 0 | 0 | 0 |
| Asset/AI | 0 | 0 | 1 | 6 | 0 | 0 | 0 |
| Material | 0 | 0 | 0 | 2 | 0 | 0 | 0 |
| Other | 0 | 0 | 9 | 5 | 5 | 2 | 1 |
| Android Deploy | **3** | 0 | 0 | 0 | 0 | 0 | 0 |
| **Total** | **172** | ~30 | **32** | **39** | **13** | **19** | **10** |
### Feature Matrix
| Feature | Godot MCP Pro | GDAI MCP ($19) | tomyud1 (free) | Dokujaa (free) | Coding-Solo (free) |
|---------|:---:|:---:|:---:|:---:|:---:|
| **Connection** | WebSocket (real-time) | stdio (Python) | WebSocket | TCP Socket | Headless CLI |
| **Undo/Redo** | Yes | Yes | No | No | No |
| **JSON-RPC 2.0** | Yes | Custom | Custom | Custom | N/A |
| **Auto-reconnect** | Yes (exponential backoff) | N/A | No | No | N/A |
| **Heartbeat** | Yes (10s ping/pong) | No | No | No | No |
| **Error suggestions** | Yes (contextual hints) | No | No | No | No |
| **Screenshot capture** | Yes (editor + game) | Yes | No | No | No |
| **Game input simulation** | Yes (key/mouse/action/sequence) | Yes (basic) | No | No | No |
| **Runtime inspection** | Yes (scene tree + properties + monitor) | No | No | No | No |
| **Signal management** | Yes (connect/disconnect/inspect) | No | No | No | No |
| **Browser visualizer** | No | No | Yes | No | No |
| **AI 3D mesh generation** | No | No | No | Yes (Meshy API) | No |
### Exclusive Categories (No Competitor Has These)
| Category | Tools | Why It Matters |
|----------|-------|----------------|
| **Animation** | 6 tools | Create animations, add tracks, set keyframes — all programmatically |
| **TileMap** | 6 tools | Set cells, fill rects, query tile data — essential for 2D level design |
| **Theme/UI** | 6 tools | StyleBox, colors, fonts — build UI themes without manual editor work |
| **Profiling** | 2 tools | FPS, memory, draw calls, physics — performance monitoring |
| **Batch/Refactor** | 8 tools | Find by type, batch property changes, cross-scene updates, dependency analysis |
| **Shader** | 6 tools | Create/edit shaders, assign materials, set parameters |
| **Export** | 3 tools | List presets, get export commands, check templates |
| **Physics** | 6 tools | Set up collision shapes, bodies, raycasts, and layer management |
| **3D Scene** | 6 tools | Add meshes, cameras, lights, environment, GridMap support |
| **Particle** | 5 tools | Create particles with custom materials, presets, and gradients |
| **Navigation** | 6 tools | Configure navigation regions, agents, pathfinding, baking |
| **Audio** | 6 tools | Complete audio bus system, effects, players, live management |
| **AnimationTree** | 4 tools | Build state machines with transitions and blend trees |
| **State Machine** | 3 tools | Advanced state machine management for complex animations |
| **Testing/QA** | 6 tools | Automated testing, assertions, stress testing, screenshot comparison |
| **Runtime** | 19 tools | Inspect and control game at runtime: inspect, record, replay, navigate |
### Architecture Advantages
| Aspect | Godot MCP Pro | Typical Competitor |
|--------|--------------|-------------------|
| **Protocol** | JSON-RPC 2.0 (standard, extensible) | Custom JSON or CLI-based |
| **Connection** | Persistent WebSocket with heartbeat | Per-command subprocess or raw TCP |
| **Reliability** | Auto-reconnect with exponential backoff (1s→60s) | Manual reconnection required |
| **Type Safety** | Smart type parsing (Vector2, Color, Rect2, hex colors) | String-only or limited types |
| **Error Handling** | Structured errors with codes + suggestions | Generic error messages |
| **Undo Support** | All mutations go through UndoRedo system | Direct modifications (no undo) |
| **Port Management** | Auto-scan ports 6505-6509 | Fixed port, conflicts possible |
## License
Proprietary — see [LICENSE](LICENSE) for details. Purchase includes lifetime updates.

View File

@@ -0,0 +1,120 @@
@tool
extends Node
var editor_plugin: EditorPlugin
var _command_handlers: Dictionary = {} # method_name -> Callable
var _disabled_tools: Dictionary = {} # method_name -> true
const TOOL_CONFIG_PATH := "user://mcp_tool_config.cfg"
func _ready() -> void:
_load_tool_config()
_register_commands()
func _register_commands() -> void:
var command_classes := [
preload("res://addons/godot_mcp/commands/project_commands.gd"),
preload("res://addons/godot_mcp/commands/scene_commands.gd"),
preload("res://addons/godot_mcp/commands/node_commands.gd"),
preload("res://addons/godot_mcp/commands/script_commands.gd"),
preload("res://addons/godot_mcp/commands/editor_commands.gd"),
preload("res://addons/godot_mcp/commands/input_commands.gd"),
preload("res://addons/godot_mcp/commands/runtime_commands.gd"),
preload("res://addons/godot_mcp/commands/animation_commands.gd"),
preload("res://addons/godot_mcp/commands/tilemap_commands.gd"),
preload("res://addons/godot_mcp/commands/theme_commands.gd"),
preload("res://addons/godot_mcp/commands/profiling_commands.gd"),
preload("res://addons/godot_mcp/commands/batch_commands.gd"),
preload("res://addons/godot_mcp/commands/shader_commands.gd"),
preload("res://addons/godot_mcp/commands/export_commands.gd"),
preload("res://addons/godot_mcp/commands/resource_commands.gd"),
preload("res://addons/godot_mcp/commands/input_map_commands.gd"),
preload("res://addons/godot_mcp/commands/scene_3d_commands.gd"),
preload("res://addons/godot_mcp/commands/physics_commands.gd"),
preload("res://addons/godot_mcp/commands/analysis_commands.gd"),
preload("res://addons/godot_mcp/commands/animation_tree_commands.gd"),
preload("res://addons/godot_mcp/commands/audio_commands.gd"),
preload("res://addons/godot_mcp/commands/navigation_commands.gd"),
preload("res://addons/godot_mcp/commands/particle_commands.gd"),
preload("res://addons/godot_mcp/commands/test_commands.gd"),
preload("res://addons/godot_mcp/commands/android_commands.gd"),
]
for cmd_class in command_classes:
var cmd: Node = cmd_class.new()
cmd.editor_plugin = editor_plugin
add_child(cmd)
var methods: Dictionary = cmd.get_commands()
for method_name: String in methods:
_command_handlers[method_name] = methods[method_name]
print("[MCP] Registered %d commands" % _command_handlers.size())
func execute(method: String, params: Dictionary) -> Dictionary:
if not _command_handlers.has(method):
return {
"error": {
"code": -32601,
"message": "Method not found: %s" % method,
"data": {"available_methods": _command_handlers.keys()}
}
}
if _disabled_tools.has(method):
return {
"error": {
"code": -32603,
"message": "Tool '%s' is disabled in MCP Server settings" % method
}
}
var handler: Callable = _command_handlers[method]
var result: Dictionary = await handler.call(params)
return result
func get_available_methods() -> Array:
return _command_handlers.keys()
func is_tool_disabled(method: String) -> bool:
return _disabled_tools.has(method)
func set_tool_disabled(method: String, disabled: bool) -> void:
if disabled:
_disabled_tools[method] = true
else:
_disabled_tools.erase(method)
_save_tool_config()
func set_all_tools_disabled(disabled: bool) -> void:
if disabled:
for method: String in _command_handlers:
_disabled_tools[method] = true
else:
_disabled_tools.clear()
_save_tool_config()
func _load_tool_config() -> void:
var cfg := ConfigFile.new()
if cfg.load(TOOL_CONFIG_PATH) != OK:
return
if not cfg.has_section("disabled_tools"):
return
for method: String in cfg.get_section_keys("disabled_tools"):
if cfg.get_value("disabled_tools", method, false):
_disabled_tools[method] = true
func _save_tool_config() -> void:
var cfg := ConfigFile.new()
for method: String in _disabled_tools:
cfg.set_value("disabled_tools", method, true)
cfg.save(TOOL_CONFIG_PATH)

View File

@@ -0,0 +1,518 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"find_unused_resources": _find_unused_resources,
"analyze_signal_flow": _analyze_signal_flow,
"analyze_scene_complexity": _analyze_scene_complexity,
"find_script_references": _find_script_references,
"detect_circular_dependencies": _detect_circular_dependencies,
"get_project_statistics": _get_project_statistics,
}
# =============================================================================
# find_unused_resources
# =============================================================================
## Scan project for resources not referenced by any .tscn, .gd, or .tres file.
func _find_unused_resources(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all resource files
var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg",
"wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material",
"theme", "stylebox", "font", "anim"]
var all_resources: Array = []
_collect_files_by_ext(path, resource_extensions, all_resources, include_addons)
# Step 2: Collect all referencing files (.tscn, .gd, .tres)
var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var ref_files: Array = []
_collect_files_by_ext(path, ref_extensions, ref_files, include_addons)
# Step 3: Build a set of all referenced paths
var referenced: Dictionary = {} # path -> true
for ref_file in ref_files:
var content := _read_file_text(ref_file as String)
if content.is_empty():
continue
# Find res:// paths in file content
var idx := 0
while idx < content.length():
var found := content.find("res://", idx)
if found == -1:
break
# Extract the path (up to quote, space, or end of line)
var end := found + 6
while end < content.length():
var c := content[end]
if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}':
break
end += 1
var ref_path := content.substr(found, end - found)
referenced[ref_path] = true
idx = end
# Step 4: Find unreferenced resources
var unused: Array = []
for res_path in all_resources:
var p: String = res_path
if not referenced.has(p):
# Also check without uid:// prefix variants — some references use uid
unused.append(p)
return success({
"unused_resources": unused,
"unused_count": unused.size(),
"total_resources_scanned": all_resources.size(),
"total_files_checked": ref_files.size(),
})
# =============================================================================
# analyze_signal_flow
# =============================================================================
## Map all signal connections in the currently edited scene.
func _analyze_signal_flow(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var nodes_data: Array = []
_collect_signal_data(root, root, nodes_data)
return success({
"scene": root.scene_file_path,
"nodes": nodes_data,
"total_nodes": nodes_data.size(),
})
func _collect_signal_data(node: Node, root: Node, out: Array) -> void:
var node_path := str(root.get_path_to(node))
var signals_emitted: Array = []
var signals_connected_to: Array = []
# Get all signals this node defines
for sig in node.get_signal_list():
var sig_name: String = sig["name"]
var connections := node.get_signal_connection_list(sig_name)
if connections.size() > 0:
var targets: Array = []
for conn in connections:
var callable: Callable = conn["callable"]
var target_node: Node = callable.get_object() as Node
var target_path := ""
if target_node != null:
target_path = str(root.get_path_to(target_node))
targets.append({
"target_node": target_path,
"method": callable.get_method(),
})
# Also record on the target side
signals_connected_to.append({
"from_node": node_path,
"signal": sig_name,
"method": callable.get_method(),
})
signals_emitted.append({
"signal": sig_name,
"targets": targets,
})
# Only include nodes that have signal activity
if signals_emitted.size() > 0 or signals_connected_to.size() > 0:
out.append({
"name": node.name,
"path": node_path,
"type": node.get_class(),
"signals_emitted": signals_emitted,
"signals_connected_to": signals_connected_to,
})
for child in node.get_children():
_collect_signal_data(child, root, out)
# =============================================================================
# analyze_scene_complexity
# =============================================================================
## Analyze a scene's complexity: node count, depth, types, scripts, potential issues.
func _analyze_scene_complexity(params: Dictionary) -> Dictionary:
var scene_path: String = optional_string(params, "path", "")
var root: Node = null
if scene_path.is_empty():
root = get_edited_root()
if root == null:
return error_no_scene()
scene_path = root.scene_file_path
else:
if not ResourceLoader.exists(scene_path):
return error_not_found("Scene '%s'" % scene_path)
var packed := ResourceLoader.load(scene_path) as PackedScene
if packed == null:
return error_internal("Failed to load scene: %s" % scene_path)
root = packed.instantiate()
var total_nodes := 0
var max_depth := 0
var types: Dictionary = {} # class_name -> count
var scripts_attached: Array = []
var resources_used: Dictionary = {} # resource path -> count
var issues: Array = []
_analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used)
# Count totals from recursive walk
total_nodes = _count_nodes_recursive(root)
max_depth = _get_max_depth(root, 0)
# Detect potential issues
if total_nodes > 1000:
issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes})
elif total_nodes > 500:
issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes})
if max_depth > 15:
issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth})
elif max_depth > 10:
issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth})
# If we instantiated the scene ourselves, free it
if not scene_path.is_empty() and root != get_edited_root():
root.queue_free()
return success({
"scene_path": scene_path,
"total_nodes": total_nodes,
"max_depth": max_depth,
"nodes_by_type": types,
"scripts_attached": scripts_attached,
"unique_resource_count": resources_used.size(),
"issues": issues,
})
func _analyze_node(node: Node, root: Node, depth: int,
total_nodes: int, max_depth: int,
types: Dictionary, scripts: Array, resources: Dictionary) -> void:
var type_name := node.get_class()
types[type_name] = types.get(type_name, 0) + 1
if node.get_script() != null:
var script: Script = node.get_script()
var script_path := script.resource_path
if not script_path.is_empty():
scripts.append({
"node": str(root.get_path_to(node)),
"script": script_path,
})
for child in node.get_children():
_analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources)
func _count_nodes_recursive(node: Node) -> int:
var count := 1
for child in node.get_children():
count += _count_nodes_recursive(child)
return count
func _get_max_depth(node: Node, current_depth: int) -> int:
var max_d := current_depth
for child in node.get_children():
var child_depth := _get_max_depth(child, current_depth + 1)
if child_depth > max_d:
max_d = child_depth
return max_d
# =============================================================================
# find_script_references
# =============================================================================
## Find all places where a given script, class_name, or resource path is used.
func _find_script_references(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
var search_files: Array = []
_collect_files_by_ext(path, search_extensions, search_files, include_addons)
var references: Array = []
for file_path in search_files:
var fp: String = file_path
var content := _read_file_text(fp)
if content.is_empty():
continue
var lines := content.split("\n")
var line_num := 0
for line in lines:
line_num += 1
var l: String = line
if l.contains(query):
references.append({
"file": fp,
"line": line_num,
"content": l.strip_edges(),
})
return success({
"query": query,
"references": references,
"reference_count": references.size(),
"files_searched": search_files.size(),
})
# =============================================================================
# detect_circular_dependencies
# =============================================================================
## Check for circular scene dependencies (.tscn files referencing each other).
func _detect_circular_dependencies(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
# Step 1: Collect all .tscn files
var tscn_files: Array = []
_collect_files_by_ext(path, ["tscn"], tscn_files, include_addons)
# Step 2: Build dependency graph: scene_path -> [referenced_scene_paths]
var dep_graph: Dictionary = {} # String -> Array[String]
for tscn_path in tscn_files:
var tp: String = tscn_path
var content := _read_file_text(tp)
if content.is_empty():
continue
var deps: Array = []
for line in content.split("\n"):
var l: String = line
# Match [ext_resource ... path="res://..." ...] lines that reference .tscn
if l.begins_with("[ext_resource") and ".tscn" in l:
var path_start := l.find('path="')
if path_start == -1:
continue
path_start += 6 # len('path="')
var path_end := l.find('"', path_start)
if path_end == -1:
continue
var ref_path := l.substr(path_start, path_end - path_start)
if ref_path.ends_with(".tscn"):
deps.append(ref_path)
dep_graph[tp] = deps
# Step 3: Detect cycles using DFS
var cycles: Array = []
var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited"
for scene in dep_graph:
visited[scene] = "unvisited"
for scene in dep_graph:
if visited[scene] == "unvisited":
var path_stack: Array = []
_dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles)
return success({
"scenes_checked": tscn_files.size(),
"circular_dependencies": cycles,
"has_circular": cycles.size() > 0,
"dependency_graph": dep_graph,
})
func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary,
path_stack: Array, cycles: Array) -> void:
visited[node] = "visiting"
path_stack.append(node)
if graph.has(node):
var deps: Array = graph[node]
for dep in deps:
var d: String = dep
if not visited.has(d):
# Scene referenced but not in our graph (might not exist or outside scope)
continue
if visited[d] == "visiting":
# Found a cycle — extract it from the stack
var cycle_start := path_stack.find(d)
var cycle: Array = path_stack.slice(cycle_start)
cycle.append(d) # Close the cycle
cycles.append(cycle)
elif visited[d] == "unvisited":
_dfs_detect_cycle(d, graph, visited, path_stack, cycles)
path_stack.pop_back()
visited[node] = "visited"
# =============================================================================
# get_project_statistics
# =============================================================================
## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins.
func _get_project_statistics(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var include_addons: bool = optional_bool(params, "include_addons", false)
var file_counts: Dictionary = {} # extension -> count
var total_script_lines := 0
var scene_count := 0
var resource_count := 0
var total_files := 0
_collect_statistics(path, include_addons, file_counts)
# Extract internal counters and remove them from the visible dict
total_script_lines = int(file_counts.get("_total_script_lines", 0))
scene_count = int(file_counts.get("_scene_count", 0))
resource_count = int(file_counts.get("_resource_count", 0))
total_files = int(file_counts.get("_total_files", 0))
file_counts.erase("_total_script_lines")
file_counts.erase("_scene_count")
file_counts.erase("_resource_count")
file_counts.erase("_total_files")
# Autoloads
var autoloads: Dictionary = {}
for prop in ProjectSettings.get_property_list():
var prop_name: String = prop["name"]
if prop_name.begins_with("autoload/"):
autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name))
# Enabled plugins
var plugins: Array = []
var plugin_cfg_path := "res://addons"
var enabled_plugins: PackedStringArray = ProjectSettings.get_setting(
"editor_plugins/enabled", PackedStringArray()
)
var plugin_dir := DirAccess.open(plugin_cfg_path)
if plugin_dir != null:
plugin_dir.list_dir_begin()
var dir_name := plugin_dir.get_next()
while not dir_name.is_empty():
if plugin_dir.current_is_dir() and not dir_name.begins_with("."):
var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg")
if FileAccess.file_exists(cfg_path):
var plugin_path := "res://addons/%s/plugin.cfg" % dir_name
plugins.append({
"name": dir_name,
"enabled": plugin_path in enabled_plugins,
})
dir_name = plugin_dir.get_next()
plugin_dir.list_dir_end()
return success({
"file_counts_by_extension": file_counts,
"total_files": total_files,
"total_script_lines": total_script_lines,
"scene_count": scene_count,
"resource_count": resource_count,
"autoloads": autoloads,
"plugins": plugins,
})
func _collect_statistics(path: String, include_addons: bool,
file_counts: Dictionary) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_statistics(full_path, include_addons, file_counts)
else:
var ext := file_name.get_extension().to_lower()
file_counts[ext] = file_counts.get(ext, 0) + 1
if ext == "gd":
var content := _read_file_text(full_path)
var line_count := content.count("\n") + 1 if not content.is_empty() else 0
# We can't modify int params, so we store in the dict
file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count
if ext == "tscn":
file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1
if ext in ["tres", "material", "theme", "stylebox", "font"]:
file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1
file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1
file_name = dir.get_next()
dir.list_dir_end()
# =============================================================================
# Shared helpers
# =============================================================================
## Recursively collect files matching given extensions.
func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if file_name == "addons" and not include_addons:
file_name = dir.get_next()
continue
_collect_files_by_ext(full_path, extensions, out, include_addons)
else:
var ext := file_name.get_extension().to_lower()
if ext in extensions:
out.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
## Read a file's text content. Returns empty string on failure.
func _read_file_text(file_path: String) -> String:
var file := FileAccess.open(file_path, FileAccess.READ)
if file == null:
return ""
var content := file.get_as_text()
file.close()
return content

View File

@@ -0,0 +1,192 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_android_devices": _list_android_devices,
"get_android_preset_info": _get_android_preset_info,
"deploy_to_android": _deploy_to_android,
}
## Resolve adb path from editor settings or PATH fallback.
func _resolve_adb_path() -> String:
var editor_settings := get_editor().get_editor_settings()
# Godot exposes this under export/android/adb (may be stored as an absolute path).
var configured: String = ""
if editor_settings.has_setting("export/android/adb"):
configured = str(editor_settings.get_setting("export/android/adb"))
if not configured.is_empty() and FileAccess.file_exists(configured):
return configured
# Fallback: assume adb is on PATH. OS.execute will resolve it at call time.
return "adb"
func _run(cmd: String, args: PackedStringArray) -> Dictionary:
var output: Array = []
var exit_code := OS.execute(cmd, args, output, true)
var stdout := ""
if not output.is_empty():
stdout = str(output[0])
return {"exit_code": exit_code, "stdout": stdout}
## List devices visible to adb.
func _list_android_devices(_params: Dictionary) -> Dictionary:
var adb := _resolve_adb_path()
var result := _run(adb, PackedStringArray(["devices", "-l"]))
if result["exit_code"] != 0:
return error(-32000, "adb failed (exit %d). Install Android platform-tools or set Editor Settings > Export > Android > Adb." % result["exit_code"], {"adb_path": adb, "output": result["stdout"]})
# Parse `adb devices -l` output:
# List of devices attached
# R58M12345 device usb:3-1 product:foo model:Pixel_5 device:redfin
var devices: Array = []
var lines: PackedStringArray = str(result["stdout"]).split("\n")
for raw_line in lines:
var line: String = raw_line.strip_edges()
if line.is_empty() or line.begins_with("List of devices") or line.begins_with("* daemon"):
continue
var parts: PackedStringArray = line.split(" ", false)
if parts.size() < 2:
continue
var dev: Dictionary = {"serial": parts[0], "state": parts[1]}
for i in range(2, parts.size()):
var kv: String = parts[i]
var eq: int = kv.find(":")
if eq > 0:
dev[kv.substr(0, eq)] = kv.substr(eq + 1)
devices.append(dev)
return success({"devices": devices, "count": devices.size(), "adb_path": adb})
## Find an Android preset in export_presets.cfg. Returns the preset dict or null.
func _find_android_preset(preset_name: String, preset_index: int) -> Dictionary:
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return {}
var cfg := ConfigFile.new()
if cfg.load(presets_path) != OK:
return {}
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
var platform := str(cfg.get_value(section, "platform", ""))
var name := str(cfg.get_value(section, "name", ""))
var matches := false
if not preset_name.is_empty():
matches = (name == preset_name)
elif preset_index >= 0:
matches = (idx == preset_index)
else:
# No filter: pick the first Android preset.
matches = (platform == "Android")
if matches:
var options_section := "preset.%d.options" % idx
var package_name := ""
if cfg.has_section(options_section):
package_name = str(cfg.get_value(options_section, "package/unique_name", ""))
return {
"index": idx,
"name": name,
"platform": platform,
"runnable": bool(cfg.get_value(section, "runnable", false)),
"export_path": str(cfg.get_value(section, "export_path", "")),
"package_name": package_name,
}
idx += 1
return {}
## Read Android preset metadata (package name, export path, etc.)
func _get_android_preset_info(params: Dictionary) -> Dictionary:
var preset_name: String = optional_string(params, "preset_name", "")
var preset_index: int = optional_int(params, "preset_index", -1)
var preset := _find_android_preset(preset_name, preset_index)
if preset.is_empty():
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
if preset["platform"] != "Android":
return error(-32000, "Preset '%s' is not an Android preset (platform=%s)" % [preset["name"], preset["platform"]])
return success(preset)
## Export APK, install it on a device, then optionally launch the main activity.
func _deploy_to_android(params: Dictionary) -> Dictionary:
var preset_name: String = optional_string(params, "preset_name", "")
var preset_index: int = optional_int(params, "preset_index", -1)
var device_serial: String = optional_string(params, "device_serial", "")
var debug: bool = optional_bool(params, "debug", true)
var launch: bool = optional_bool(params, "launch", true)
var skip_export: bool = optional_bool(params, "skip_export", false)
var preset := _find_android_preset(preset_name, preset_index)
if preset.is_empty():
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
if preset["platform"] != "Android":
return error(-32000, "Preset '%s' is not an Android preset" % preset["name"])
var export_path_res: String = preset["export_path"]
if export_path_res.is_empty():
return error(-32000, "Export path not configured for preset '%s'" % preset["name"])
var export_path_abs: String = ProjectSettings.globalize_path(export_path_res) if export_path_res.begins_with("res://") else export_path_res
var steps: Array = []
# Step 1: Export APK via Godot CLI (unless caller already has an APK).
if not skip_export:
var godot_bin := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://")
var export_flag := "--export-debug" if debug else "--export-release"
var export_args := PackedStringArray(["--headless", "--path", project_dir, export_flag, preset["name"], export_path_abs])
var export_result := _run(godot_bin, export_args)
steps.append({"step": "export", "command": godot_bin, "args": export_args, "exit_code": export_result["exit_code"]})
if export_result["exit_code"] != 0:
return error(-32000, "Godot export failed (exit %d). See stdout." % export_result["exit_code"], {"steps": steps, "stdout": export_result["stdout"]})
if not FileAccess.file_exists(export_path_abs):
return error(-32000, "APK not found at %s after export" % export_path_abs, {"steps": steps})
# Step 2: adb install -r
var adb := _resolve_adb_path()
var install_args := PackedStringArray()
if not device_serial.is_empty():
install_args.append("-s")
install_args.append(device_serial)
install_args.append("install")
install_args.append("-r")
install_args.append(export_path_abs)
var install_result := _run(adb, install_args)
steps.append({"step": "install", "command": adb, "args": install_args, "exit_code": install_result["exit_code"], "stdout": install_result["stdout"]})
if install_result["exit_code"] != 0:
return error(-32000, "adb install failed (exit %d)" % install_result["exit_code"], {"steps": steps})
# Step 3: adb shell am start (optional)
if launch:
var package_name: String = preset["package_name"]
if package_name.is_empty():
steps.append({"step": "launch", "skipped": true, "reason": "package_name not found in preset"})
else:
var launch_args := PackedStringArray()
if not device_serial.is_empty():
launch_args.append("-s")
launch_args.append(device_serial)
launch_args.append("shell")
launch_args.append("monkey")
launch_args.append("-p")
launch_args.append(package_name)
launch_args.append("-c")
launch_args.append("android.intent.category.LAUNCHER")
launch_args.append("1")
var launch_result := _run(adb, launch_args)
steps.append({"step": "launch", "command": adb, "args": launch_args, "exit_code": launch_result["exit_code"], "stdout": launch_result["stdout"]})
return success({
"preset": preset["name"],
"apk_path": export_path_abs,
"device": device_serial if not device_serial.is_empty() else "(default)",
"package_name": preset["package_name"],
"steps": steps,
})

View File

@@ -0,0 +1,298 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_animations": _list_animations,
"create_animation": _create_animation,
"add_animation_track": _add_animation_track,
"set_animation_keyframe": _set_animation_keyframe,
"get_animation_info": _get_animation_info,
"remove_animation": _remove_animation,
}
func _find_animation_player(node_path: String) -> AnimationPlayer:
var node := find_node_by_path(node_path)
if node is AnimationPlayer:
return node as AnimationPlayer
return null
func _list_animations(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var animations: Array = []
for anim_name in player.get_animation_list():
var anim := player.get_animation(anim_name)
animations.append({
"name": anim_name,
"length": anim.length,
"loop_mode": anim.loop_mode,
"track_count": anim.get_track_count(),
})
return success({"node_path": node_path, "animations": animations, "count": animations.size()})
func _create_animation(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var length: float = float(params.get("length", 1.0))
var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong
var anim := Animation.new()
anim.length = length
anim.loop_mode = loop_mode as Animation.LoopMode
var lib := player.get_animation_library("")
var created_library := false
if lib == null:
lib = AnimationLibrary.new()
created_library = true
if lib.has_animation(anim_name):
return error_invalid_params("Animation '%s' already exists" % anim_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Create animation %s" % anim_name)
if created_library:
undo_redo.add_do_method(player, "add_animation_library", "", lib)
undo_redo.add_do_reference(lib)
undo_redo.add_undo_method(player, "remove_animation_library", "")
undo_redo.add_do_method(lib, "add_animation", anim_name, anim)
undo_redo.add_do_reference(anim)
undo_redo.add_undo_method(lib, "remove_animation", anim_name)
undo_redo.commit_action()
return success({"name": anim_name, "length": length, "created": true})
func _add_animation_track(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var result3 := require_string(params, "track_path")
if result3[1] != null:
return result3[1]
var track_path: String = result3[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var track_type_str: String = optional_string(params, "track_type", "value")
var track_type: int
match track_type_str:
"value": track_type = Animation.TYPE_VALUE
"position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too
"rotation_2d": track_type = Animation.TYPE_ROTATION_3D
"scale_2d": track_type = Animation.TYPE_SCALE_3D
"method": track_type = Animation.TYPE_METHOD
"bezier": track_type = Animation.TYPE_BEZIER
"blend_shape": track_type = Animation.TYPE_BLEND_SHAPE
_: track_type = Animation.TYPE_VALUE
var track_idx := anim.get_track_count()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add animation track")
undo_redo.add_do_method(anim, "add_track", track_type, track_idx)
undo_redo.add_do_method(anim, "track_set_path", track_idx, NodePath(track_path))
var update_mode_str: String = optional_string(params, "update_mode", "")
if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE:
match update_mode_str:
"continuous": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CONTINUOUS)
"discrete": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_DISCRETE)
"capture": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CAPTURE)
undo_redo.add_undo_method(anim, "remove_track", track_idx)
undo_redo.commit_action()
return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str})
func _set_animation_keyframe(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var track_index: int = int(params.get("track_index", 0))
if track_index < 0 or track_index >= anim.get_track_count():
return error_invalid_params("Invalid track_index: %d" % track_index)
var time: float = float(params.get("time", 0.0))
var value = params.get("value")
# Parse value string for common types
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var easing: float = float(params.get("easing", 1.0))
var old_key_idx := _find_animation_key_at_time(anim, track_index, time)
var had_old_key := old_key_idx >= 0
var old_value: Variant = anim.track_get_key_value(track_index, old_key_idx) if had_old_key else null
var old_easing: float = anim.track_get_key_transition(track_index, old_key_idx) if had_old_key else 1.0
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set animation keyframe")
undo_redo.add_do_method(self, "_upsert_animation_key", anim, track_index, time, value, easing)
undo_redo.add_undo_method(self, "_restore_animation_key", anim, track_index, time, had_old_key, old_value, old_easing)
undo_redo.commit_action()
var key_idx := _find_animation_key_at_time(anim, track_index, time)
return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)})
func _get_animation_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "animation")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var anim := player.get_animation(anim_name)
if anim == null:
return error_not_found("Animation '%s'" % anim_name)
var tracks: Array = []
for i in anim.get_track_count():
var track_info := {
"index": i,
"path": str(anim.track_get_path(i)),
"type": anim.track_get_type(i),
"key_count": anim.track_get_key_count(i),
}
var keys: Array = []
for k in anim.track_get_key_count(i):
keys.append({
"time": anim.track_get_key_time(i, k),
"value": str(anim.track_get_key_value(i, k)),
"easing": anim.track_get_key_transition(i, k),
})
track_info["keys"] = keys
tracks.append(track_info)
return success({
"name": anim_name,
"length": anim.length,
"loop_mode": anim.loop_mode,
"step": anim.step,
"tracks": tracks,
})
func _remove_animation(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var anim_name: String = result2[0]
var player := _find_animation_player(node_path)
if player == null:
return error_not_found("AnimationPlayer at '%s'" % node_path)
var lib := player.get_animation_library("")
if lib == null or not lib.has_animation(anim_name):
return error_not_found("Animation '%s'" % anim_name)
var anim := lib.get_animation(anim_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove animation %s" % anim_name)
undo_redo.add_do_method(lib, "remove_animation", anim_name)
undo_redo.add_undo_method(lib, "add_animation", anim_name, anim)
undo_redo.add_undo_reference(anim)
undo_redo.commit_action()
return success({"name": anim_name, "removed": true})
func _find_animation_key_at_time(anim: Animation, track_index: int, time: float) -> int:
for key_index: int in anim.track_get_key_count(track_index):
if is_equal_approx(anim.track_get_key_time(track_index, key_index), time):
return key_index
return -1
func _upsert_animation_key(anim: Animation, track_index: int, time: float, value: Variant, easing: float) -> void:
var key_idx := _find_animation_key_at_time(anim, track_index, time)
if key_idx < 0:
key_idx = anim.track_insert_key(track_index, time, value)
else:
anim.track_set_key_value(track_index, key_idx, value)
if easing != 1.0:
anim.track_set_key_transition(track_index, key_idx, easing)
func _restore_animation_key(anim: Animation, track_index: int, time: float, had_old_key: bool, old_value: Variant, old_easing: float) -> void:
var key_idx := _find_animation_key_at_time(anim, track_index, time)
if had_old_key:
if key_idx < 0:
key_idx = anim.track_insert_key(track_index, time, old_value)
else:
anim.track_set_key_value(track_index, key_idx, old_value)
anim.track_set_key_transition(track_index, key_idx, old_easing)
elif key_idx >= 0:
anim.track_remove_key(track_index, key_idx)

View File

@@ -0,0 +1,601 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_animation_tree": _create_animation_tree,
"get_animation_tree_structure": _get_animation_tree_structure,
"add_state_machine_state": _add_state_machine_state,
"remove_state_machine_state": _remove_state_machine_state,
"add_state_machine_transition": _add_state_machine_transition,
"remove_state_machine_transition": _remove_state_machine_transition,
"set_blend_tree_node": _set_blend_tree_node,
"set_tree_parameter": _set_tree_parameter,
}
## Find AnimationTree on a node or return null
func _find_animation_tree(node_path: String) -> AnimationTree:
var node := find_node_by_path(node_path)
if node is AnimationTree:
return node as AnimationTree
return null
## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState")
## Returns [state_machine, error_or_null]
func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array:
var root := tree.tree_root
if not root is AnimationNodeStateMachine:
return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")]
if sm_path.is_empty() or sm_path == ".":
return [root as AnimationNodeStateMachine, null]
var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine
var parts := sm_path.split("/")
for part in parts:
if not current.has_node(StringName(part)):
return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])]
var child := current.get_node(StringName(part))
if not child is AnimationNodeStateMachine:
return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)]
current = child as AnimationNodeStateMachine
return [current, null]
## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine,
## or a slash-separated path. The last segment is the BlendTree node name.
## Returns [blend_tree, error_or_null]
func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array:
var result := _resolve_state_machine(tree, sm_path)
if result[1] != null:
return result
var sm: AnimationNodeStateMachine = result[0]
if not sm.has_node(StringName(bt_name)):
return [null, error_not_found("BlendTree node '%s'" % bt_name)]
var node := sm.get_node(StringName(bt_name))
if not node is AnimationNodeBlendTree:
return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)]
return [node as AnimationNodeBlendTree, null]
func _create_animation_tree(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(node_path)
if parent == null:
return error_not_found("Node at '%s'" % node_path)
var anim_player_path: String = optional_string(params, "anim_player", "")
var tree_name: String = optional_string(params, "name", "AnimationTree")
# Create the AnimationTree
var tree := AnimationTree.new()
tree.name = tree_name
# Set root to AnimationNodeStateMachine
var state_machine := AnimationNodeStateMachine.new()
tree.tree_root = state_machine
# Link to AnimationPlayer if provided
if not anim_player_path.is_empty():
tree.anim_player = NodePath(anim_player_path)
add_child_with_undo(parent, tree, root, "MCP: Create AnimationTree")
return success({
"name": tree.name,
"node_path": str(root.get_path_to(tree)),
"root_type": "AnimationNodeStateMachine",
"anim_player": anim_player_path,
"created": true,
})
func _get_animation_tree_structure(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var root := tree.tree_root
if root == null:
return success({"node_path": node_path, "root": null})
var structure := _read_node_structure(root)
structure["active"] = tree.active
structure["anim_player"] = str(tree.anim_player)
structure["node_path"] = node_path
return success(structure)
func _read_node_structure(node: AnimationNode) -> Dictionary:
if node is AnimationNodeStateMachine:
return _read_state_machine_structure(node as AnimationNodeStateMachine)
elif node is AnimationNodeBlendTree:
return _read_blend_tree_structure(node as AnimationNodeBlendTree)
elif node is AnimationNodeAnimation:
var anim_node := node as AnimationNodeAnimation
return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)}
else:
return {"type": node.get_class()}
func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary:
var states: Array = []
# Iterate through graph nodes via get_node_name
# AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes
var node_list := _get_sm_node_names(sm)
for state_name in node_list:
var child := sm.get_node(StringName(state_name))
var state_info := {
"name": state_name,
"position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y},
}
state_info.merge(_read_node_structure(child))
states.append(state_info)
var transitions: Array = []
for i in sm.get_transition_count():
var from_node := sm.get_transition_from(i)
var to_node := sm.get_transition_to(i)
var trans := sm.get_transition(i)
var trans_info := {
"from": str(from_node),
"to": str(to_node),
"switch_mode": trans.switch_mode,
"advance_mode": trans.advance_mode,
}
if not trans.advance_expression.is_empty():
trans_info["advance_expression"] = trans.advance_expression
if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO:
trans_info["auto"] = true
transitions.append(trans_info)
return {
"type": "AnimationNodeStateMachine",
"states": states,
"transitions": transitions,
}
func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array:
# Use the internal _get_child_nodes or iterate known patterns
# AnimationNodeStateMachine doesn't expose a simple list method,
# but we can use get_graph_offset and iterate via has_node with common checks.
# Actually in Godot 4.x we can get the node list by checking property list
# or using the script resource approach. The most reliable is iterating through
# the resource properties.
var names: Array = []
var prop_list := sm.get_property_list()
for prop in prop_list:
var pname: String = prop["name"]
# State machine stores nodes as "states/<name>/node"
if pname.begins_with("states/") and pname.ends_with("/node"):
var state_name := pname.get_slice("/", 1)
if state_name != "Start" and state_name != "End":
names.append(state_name)
return names
func _read_blend_tree_structure(bt: AnimationNodeBlendTree) -> Dictionary:
var nodes_info: Array = []
var prop_list := bt.get_property_list()
var node_names: Array = []
for prop in prop_list:
var pname: String = prop["name"]
if pname.begins_with("nodes/") and pname.ends_with("/node"):
var n := pname.get_slice("/", 1)
if n != "output":
node_names.append(n)
for n_name in node_names:
var child: AnimationNode = bt.get_node(StringName(n_name))
var node_info := {
"name": n_name,
"type": child.get_class(),
"position": {"x": bt.get_node_position(StringName(n_name)).x, "y": bt.get_node_position(StringName(n_name)).y},
}
if child is AnimationNodeAnimation:
node_info["animation"] = str((child as AnimationNodeAnimation).animation)
nodes_info.append(node_info)
# Read connections
# BlendTree connections are stored as "node_connections" in properties
# We can read them from the resource property list
for prop in prop_list:
var pname: String = prop["name"]
if pname.begins_with("nodes/") and pname.ends_with("/node"):
continue
if pname.begins_with("nodes/") and pname.ends_with("/position"):
continue
# Connection format: "node_connection/<idx>/<input_node>/<input_port>"
# Actually connections are stored differently - let's skip for now
return {
"type": "AnimationNodeBlendTree",
"nodes": nodes_info,
}
func _add_state_machine_state(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "state_name")
if result2[1] != null:
return result2[1]
var state_name: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
if sm.has_node(StringName(state_name)):
return error_invalid_params("State '%s' already exists" % state_name)
var state_type: String = optional_string(params, "state_type", "animation")
var position_x: float = float(params.get("position_x", 0.0))
var position_y: float = float(params.get("position_y", 0.0))
var position := Vector2(position_x, position_y)
var node: AnimationNode
match state_type:
"animation":
var anim_node := AnimationNodeAnimation.new()
var anim_name: String = optional_string(params, "animation", "")
if not anim_name.is_empty():
anim_node.animation = StringName(anim_name)
node = anim_node
"blend_tree":
node = AnimationNodeBlendTree.new()
"state_machine":
node = AnimationNodeStateMachine.new()
_:
return error_invalid_params("Unknown state_type: '%s'. Use 'animation', 'blend_tree', or 'state_machine'" % state_type)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add state machine state")
undo_redo.add_do_method(sm, "add_node", StringName(state_name), node, position)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(sm, "remove_node", StringName(state_name))
undo_redo.commit_action()
return success({
"state_name": state_name,
"state_type": state_type,
"position": {"x": position_x, "y": position_y},
"added": true,
})
func _remove_state_machine_state(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "state_name")
if result2[1] != null:
return result2[1]
var state_name: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
if not sm.has_node(StringName(state_name)):
return error_not_found("State '%s'" % state_name)
var old_node := sm.get_node(StringName(state_name))
var old_position := sm.get_node_position(StringName(state_name))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove state machine state")
undo_redo.add_do_method(sm, "remove_node", StringName(state_name))
undo_redo.add_undo_method(sm, "add_node", StringName(state_name), old_node, old_position)
undo_redo.add_undo_reference(old_node)
undo_redo.commit_action()
return success({"state_name": state_name, "removed": true})
func _add_state_machine_transition(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "from_state")
if result2[1] != null:
return result2[1]
var from_state: String = result2[0]
var result3 := require_string(params, "to_state")
if result3[1] != null:
return result3[1]
var to_state: String = result3[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
# Validate states exist (Start and End are special built-in nodes)
if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)):
return error_not_found("State '%s'" % from_state)
if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)):
return error_not_found("State '%s'" % to_state)
var transition := AnimationNodeStateMachineTransition.new()
# switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2
var switch_mode_str: String = optional_string(params, "switch_mode", "immediate")
match switch_mode_str:
"at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
"immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
"sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly
_: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
# advance_mode: DISABLED=0, ENABLED=1, AUTO=2
var advance_mode_str: String = optional_string(params, "advance_mode", "enabled")
match advance_mode_str:
"disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED
"enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
"auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
_: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
# advance_expression
var expression: String = optional_string(params, "advance_expression", "")
if not expression.is_empty():
transition.advance_expression = expression
# xfade_time
if params.has("xfade_time"):
transition.xfade_time = float(params["xfade_time"])
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add state machine transition")
undo_redo.add_do_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
undo_redo.add_do_reference(transition)
undo_redo.add_undo_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
undo_redo.commit_action()
return success({
"from": from_state,
"to": to_state,
"switch_mode": switch_mode_str,
"advance_mode": advance_mode_str,
"advance_expression": expression,
"added": true,
})
func _remove_state_machine_transition(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "from_state")
if result2[1] != null:
return result2[1]
var from_state: String = result2[0]
var result3 := require_string(params, "to_state")
if result3[1] != null:
return result3[1]
var to_state: String = result3[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var sm_result := _resolve_state_machine(tree, sm_path)
if sm_result[1] != null:
return sm_result[1]
var sm: AnimationNodeStateMachine = sm_result[0]
# Check if transition exists
var found := false
for i in sm.get_transition_count():
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
found = true
break
if not found:
return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state])
var transition: AnimationNodeStateMachineTransition = null
for i in sm.get_transition_count():
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
transition = sm.get_transition(i)
break
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Remove state machine transition")
undo_redo.add_do_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
undo_redo.add_undo_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
undo_redo.add_undo_reference(transition)
undo_redo.commit_action()
return success({"from": from_state, "to": to_state, "removed": true})
func _set_blend_tree_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "blend_tree_state")
if result2[1] != null:
return result2[1]
var bt_state: String = result2[0]
var result3 := require_string(params, "bt_node_name")
if result3[1] != null:
return result3[1]
var bt_node_name: String = result3[0]
var result4 := require_string(params, "bt_node_type")
if result4[1] != null:
return result4[1]
var bt_node_type: String = result4[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
var sm_path: String = optional_string(params, "state_machine_path", "")
var bt_result := _resolve_blend_tree(tree, sm_path, bt_state)
if bt_result[1] != null:
return bt_result[1]
var bt: AnimationNodeBlendTree = bt_result[0]
var position_x: float = float(params.get("position_x", 0.0))
var position_y: float = float(params.get("position_y", 0.0))
var position := Vector2(position_x, position_y)
var had_old_node := bt.has_node(StringName(bt_node_name))
var old_node: AnimationNode = bt.get_node(StringName(bt_node_name)) if had_old_node else null
var old_position := bt.get_node_position(StringName(bt_node_name)) if had_old_node else Vector2.ZERO
var node: AnimationNode
match bt_node_type:
"Animation":
var anim_node := AnimationNodeAnimation.new()
var anim_name: String = optional_string(params, "animation", "")
if not anim_name.is_empty():
anim_node.animation = StringName(anim_name)
node = anim_node
"Add2":
node = AnimationNodeAdd2.new()
"Blend2":
node = AnimationNodeBlend2.new()
"Add3":
node = AnimationNodeAdd3.new()
"Blend3":
node = AnimationNodeBlend3.new()
"TimeScale":
node = AnimationNodeTimeScale.new()
"TimeSeek":
node = AnimationNodeTimeSeek.new()
"Transition":
node = AnimationNodeTransition.new()
"OneShot":
node = AnimationNodeOneShot.new()
"Sub2":
node = AnimationNodeSub2.new()
_:
return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set blend tree node")
if had_old_node:
undo_redo.add_do_method(bt, "remove_node", StringName(bt_node_name))
undo_redo.add_undo_method(bt, "add_node", StringName(bt_node_name), old_node, old_position)
undo_redo.add_undo_reference(old_node)
undo_redo.add_do_method(bt, "add_node", StringName(bt_node_name), node, position)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(bt, "remove_node", StringName(bt_node_name))
# Connect to another node if specified
var connect_to: String = optional_string(params, "connect_to", "")
var connect_port: int = optional_int(params, "connect_port", 0)
if not connect_to.is_empty():
undo_redo.add_do_method(bt, "connect_node", StringName(connect_to), connect_port, StringName(bt_node_name))
undo_redo.commit_action()
var connected_to_value: Variant = null
if not connect_to.is_empty():
connected_to_value = connect_to
return success({
"blend_tree_state": bt_state,
"bt_node_name": bt_node_name,
"bt_node_type": bt_node_type,
"position": {"x": position_x, "y": position_y},
"connected_to": connected_to_value,
"added": true,
})
func _set_tree_parameter(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "parameter")
if result2[1] != null:
return result2[1]
var parameter: String = result2[0]
var tree := _find_animation_tree(node_path)
if tree == null:
return error_not_found("AnimationTree at '%s'" % node_path)
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Prefix with "parameters/" if not already
if not parameter.begins_with("parameters/"):
parameter = "parameters/" + parameter
# Parse string values for common types
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
set_property_with_undo(tree, parameter, value, "MCP: Set AnimationTree parameter")
# Read back to confirm
var actual = tree.get(parameter)
return success({
"parameter": parameter,
"value": str(actual),
"set": true,
})

View File

@@ -0,0 +1,431 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_audio_bus_layout": _get_audio_bus_layout,
"add_audio_bus": _add_audio_bus,
"set_audio_bus": _set_audio_bus,
"add_audio_bus_effect": _add_audio_bus_effect,
"add_audio_player": _add_audio_player,
"get_audio_info": _get_audio_info,
}
func _get_audio_bus_layout(_params: Dictionary) -> Dictionary:
var buses: Array[Dictionary] = []
for i in range(AudioServer.bus_count):
var bus_data := {
"index": i,
"name": AudioServer.get_bus_name(i),
"volume_db": AudioServer.get_bus_volume_db(i),
"solo": AudioServer.is_bus_solo(i),
"mute": AudioServer.is_bus_mute(i),
"bypass_effects": AudioServer.is_bus_bypassing_effects(i),
"send": AudioServer.get_bus_send(i),
"effects": [],
}
var effects: Array[Dictionary] = []
for j in range(AudioServer.get_bus_effect_count(i)):
var effect := AudioServer.get_bus_effect(i, j)
var effect_data := {
"index": j,
"type": effect.get_class(),
"enabled": AudioServer.is_bus_effect_enabled(i, j),
}
# Include effect-specific parameters
effect_data["params"] = _get_effect_params(effect)
effects.append(effect_data)
bus_data["effects"] = effects
buses.append(bus_data)
return success({"bus_count": AudioServer.bus_count, "buses": buses})
func _get_effect_params(effect: AudioEffect) -> Dictionary:
var params := {}
if effect is AudioEffectReverb:
var rev := effect as AudioEffectReverb
params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread}
elif effect is AudioEffectDelay:
var d := effect as AudioEffectDelay
params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db}
elif effect is AudioEffectCompressor:
var c := effect as AudioEffectCompressor
params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain}
elif effect is AudioEffectLimiter:
var l := effect as AudioEffectLimiter
params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio}
elif effect is AudioEffectDistortion:
var dist := effect as AudioEffectDistortion
params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive}
elif effect is AudioEffectChorus:
var ch := effect as AudioEffectChorus
params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet}
elif effect is AudioEffectPhaser:
var ph := effect as AudioEffectPhaser
params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth}
elif effect is AudioEffectFilter:
# Covers LowPassFilter, HighPassFilter, BandPassFilter, etc.
var f := effect as AudioEffectFilter
params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db}
elif effect is AudioEffectAmplify:
var a := effect as AudioEffectAmplify
params = {"volume_db": a.volume_db}
return params
func _add_audio_bus(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
# Check if bus name already exists
for i in range(AudioServer.bus_count):
if AudioServer.get_bus_name(i) == bus_name:
return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i])
var at_position: int = optional_int(params, "at_position", -1)
AudioServer.add_bus(at_position)
var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position
AudioServer.set_bus_name(idx, bus_name)
if params.has("volume_db"):
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
var send: String = optional_string(params, "send", "")
if not send.is_empty():
AudioServer.set_bus_send(idx, send)
if params.has("solo"):
AudioServer.set_bus_solo(idx, bool(params["solo"]))
if params.has("mute"):
AudioServer.set_bus_mute(idx, bool(params["mute"]))
return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count})
func _set_audio_bus(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
var idx := AudioServer.get_bus_index(bus_name)
if idx < 0:
return error_not_found("Audio bus '%s'" % bus_name)
var changes := 0
if params.has("volume_db"):
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
changes += 1
if params.has("solo"):
AudioServer.set_bus_solo(idx, bool(params["solo"]))
changes += 1
if params.has("mute"):
AudioServer.set_bus_mute(idx, bool(params["mute"]))
changes += 1
if params.has("bypass_effects"):
AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"]))
changes += 1
var send: String = optional_string(params, "send", "")
if not send.is_empty():
AudioServer.set_bus_send(idx, send)
changes += 1
if params.has("rename"):
var new_name: String = str(params["rename"])
AudioServer.set_bus_name(idx, new_name)
bus_name = new_name
changes += 1
return success({"name": bus_name, "index": idx, "changes": changes})
func _add_audio_bus_effect(params: Dictionary) -> Dictionary:
var result := require_string(params, "bus")
if result[1] != null:
return result[1]
var bus_name: String = result[0]
var result2 := require_string(params, "effect_type")
if result2[1] != null:
return result2[1]
var effect_type: String = result2[0]
var bus_idx := AudioServer.get_bus_index(bus_name)
if bus_idx < 0:
return error_not_found("Audio bus '%s'" % bus_name)
var effect: AudioEffect = null
var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {}
match effect_type.to_lower():
"reverb":
var e := AudioEffectReverb.new()
if effect_params.has("room_size"):
e.room_size = float(effect_params["room_size"])
if effect_params.has("damping"):
e.damping = float(effect_params["damping"])
if effect_params.has("wet"):
e.wet = float(effect_params["wet"])
if effect_params.has("dry"):
e.dry = float(effect_params["dry"])
if effect_params.has("spread"):
e.spread = float(effect_params["spread"])
effect = e
"chorus":
var e := AudioEffectChorus.new()
if effect_params.has("voice_count"):
e.voice_count = int(effect_params["voice_count"])
if effect_params.has("dry"):
e.dry = float(effect_params["dry"])
if effect_params.has("wet"):
e.wet = float(effect_params["wet"])
effect = e
"delay":
var e := AudioEffectDelay.new()
if effect_params.has("tap1_active"):
e.tap1_active = bool(effect_params["tap1_active"])
if effect_params.has("tap1_delay_ms"):
e.tap1_delay_ms = float(effect_params["tap1_delay_ms"])
if effect_params.has("tap1_level_db"):
e.tap1_level_db = float(effect_params["tap1_level_db"])
if effect_params.has("tap2_active"):
e.tap2_active = bool(effect_params["tap2_active"])
if effect_params.has("tap2_delay_ms"):
e.tap2_delay_ms = float(effect_params["tap2_delay_ms"])
if effect_params.has("tap2_level_db"):
e.tap2_level_db = float(effect_params["tap2_level_db"])
effect = e
"compressor":
var e := AudioEffectCompressor.new()
if effect_params.has("threshold"):
e.threshold = float(effect_params["threshold"])
if effect_params.has("ratio"):
e.ratio = float(effect_params["ratio"])
if effect_params.has("attack_us"):
e.attack_us = float(effect_params["attack_us"])
if effect_params.has("release_ms"):
e.release_ms = float(effect_params["release_ms"])
if effect_params.has("gain"):
e.gain = float(effect_params["gain"])
if effect_params.has("mix"):
e.mix = float(effect_params["mix"])
effect = e
"limiter":
var e := AudioEffectLimiter.new()
if effect_params.has("ceiling_db"):
e.ceiling_db = float(effect_params["ceiling_db"])
if effect_params.has("threshold_db"):
e.threshold_db = float(effect_params["threshold_db"])
if effect_params.has("soft_clip_db"):
e.soft_clip_db = float(effect_params["soft_clip_db"])
if effect_params.has("soft_clip_ratio"):
e.soft_clip_ratio = float(effect_params["soft_clip_ratio"])
effect = e
"phaser":
var e := AudioEffectPhaser.new()
if effect_params.has("range_min_hz"):
e.range_min_hz = float(effect_params["range_min_hz"])
if effect_params.has("range_max_hz"):
e.range_max_hz = float(effect_params["range_max_hz"])
if effect_params.has("rate_hz"):
e.rate_hz = float(effect_params["rate_hz"])
if effect_params.has("feedback"):
e.feedback = float(effect_params["feedback"])
if effect_params.has("depth"):
e.depth = float(effect_params["depth"])
effect = e
"distortion":
var e := AudioEffectDistortion.new()
if effect_params.has("mode"):
e.mode = int(effect_params["mode"]) as AudioEffectDistortion.Mode
if effect_params.has("pre_gain"):
e.pre_gain = float(effect_params["pre_gain"])
if effect_params.has("post_gain"):
e.post_gain = float(effect_params["post_gain"])
if effect_params.has("keep_hf_hz"):
e.keep_hf_hz = float(effect_params["keep_hf_hz"])
if effect_params.has("drive"):
e.drive = float(effect_params["drive"])
effect = e
"lowpassfilter", "lowpass":
var e := AudioEffectLowPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"highpassfilter", "highpass":
var e := AudioEffectHighPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"bandpassfilter", "bandpass":
var e := AudioEffectBandPassFilter.new()
if effect_params.has("cutoff_hz"):
e.cutoff_hz = float(effect_params["cutoff_hz"])
if effect_params.has("resonance"):
e.resonance = float(effect_params["resonance"])
effect = e
"amplify":
var e := AudioEffectAmplify.new()
if effect_params.has("volume_db"):
e.volume_db = float(effect_params["volume_db"])
effect = e
"eq":
var e := AudioEffectEQ.new()
effect = e
_:
return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type)
var at_position: int = optional_int(params, "at_position", -1)
AudioServer.add_bus_effect(bus_idx, effect, at_position)
var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position
return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx})
func _add_audio_player(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var player_name: String = result2[0]
var player_type: String = optional_string(params, "type", "AudioStreamPlayer")
var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"]
if player_type not in valid_types:
return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)])
var parent := find_node_by_path(node_path)
if parent == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var player: Node = null
match player_type:
"AudioStreamPlayer":
player = AudioStreamPlayer.new()
"AudioStreamPlayer2D":
player = AudioStreamPlayer2D.new()
"AudioStreamPlayer3D":
player = AudioStreamPlayer3D.new()
player.name = player_name
# Set stream if provided
var stream_path: String = optional_string(params, "stream", "")
if not stream_path.is_empty():
if ResourceLoader.exists(stream_path):
var stream = ResourceLoader.load(stream_path)
if stream is AudioStream:
player.set("stream", stream)
else:
player.queue_free()
return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path)
else:
player.queue_free()
return error_not_found("Audio stream at '%s'" % stream_path)
# Common properties
if params.has("volume_db"):
player.set("volume_db", float(params["volume_db"]))
var bus: String = optional_string(params, "bus", "")
if not bus.is_empty():
player.set("bus", bus)
if params.has("autoplay"):
player.set("autoplay", bool(params["autoplay"]))
# 2D-specific properties
if player is AudioStreamPlayer2D:
if params.has("max_distance"):
(player as AudioStreamPlayer2D).max_distance = float(params["max_distance"])
if params.has("attenuation"):
(player as AudioStreamPlayer2D).attenuation = float(params["attenuation"])
# 3D-specific properties
if player is AudioStreamPlayer3D:
if params.has("max_distance"):
(player as AudioStreamPlayer3D).max_distance = float(params["max_distance"])
if params.has("attenuation_model"):
(player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) as AudioStreamPlayer3D.AttenuationModel
if params.has("unit_size"):
(player as AudioStreamPlayer3D).unit_size = float(params["unit_size"])
add_child_with_undo(parent, player, root, "MCP: Add audio player")
return success({
"name": player_name,
"type": player_type,
"parent": node_path,
"stream": stream_path,
"bus": player.get("bus"),
"volume_db": player.get("volume_db"),
"autoplay": player.get("autoplay"),
})
func _get_audio_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var players: Array[Dictionary] = []
_collect_audio_players(node, players)
return success({"node_path": node_path, "audio_player_count": players.size(), "players": players})
func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void:
if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D:
var info := {
"name": node.name,
"path": str(get_edited_root().get_path_to(node)),
"type": node.get_class(),
"volume_db": node.get("volume_db"),
"bus": node.get("bus"),
"autoplay": node.get("autoplay"),
"playing": node.get("playing"),
"stream": "",
}
var stream = node.get("stream")
if stream != null and stream is AudioStream:
info["stream"] = stream.resource_path
if node is AudioStreamPlayer2D:
info["max_distance"] = (node as AudioStreamPlayer2D).max_distance
info["attenuation"] = (node as AudioStreamPlayer2D).attenuation
elif node is AudioStreamPlayer3D:
info["max_distance"] = (node as AudioStreamPlayer3D).max_distance
info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model
info["unit_size"] = (node as AudioStreamPlayer3D).unit_size
result.append(info)
for child in node.get_children():
_collect_audio_players(child, result)

View File

@@ -0,0 +1,255 @@
@tool
extends Node
var editor_plugin: EditorPlugin
## Override in subclasses: return {"method_name": Callable}
func get_commands() -> Dictionary:
return {}
## Helper: return a success result
func success(data: Dictionary = {}) -> Dictionary:
return {"result": data}
## Helper: return an error
func error(code: int, message: String, data: Dictionary = {}) -> Dictionary:
var err := {"code": code, "message": message}
if not data.is_empty():
err["data"] = data
return {"error": err}
## Error codes
func error_not_found(what: String, suggestion: String = "") -> Dictionary:
var data := {}
if suggestion:
data["suggestion"] = suggestion
return error(-32001, "%s not found" % what, data)
func error_invalid_params(message: String) -> Dictionary:
return error(-32602, message)
func error_no_scene() -> Dictionary:
return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"})
func error_internal(message: String) -> Dictionary:
return error(-32603, "Internal error: %s" % message)
func error_conflict(message: String, data: Dictionary = {}) -> Dictionary:
return error(-32009, message, data)
## Get required string param
func require_string(params: Dictionary, key: String) -> Array:
if not params.has(key) or not params[key] is String or (params[key] as String).is_empty():
return [null, error_invalid_params("Missing required parameter: %s" % key)]
return [params[key] as String, null]
## Get optional string param with default
func optional_string(params: Dictionary, key: String, default: String = "") -> String:
if params.has(key) and params[key] is String:
return params[key] as String
return default
## Get optional bool param with default
func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool:
if params.has(key) and params[key] is bool:
return params[key] as bool
return default
## Get optional int param with default
func optional_int(params: Dictionary, key: String, default: int = 0) -> int:
if params.has(key):
return int(params[key])
return default
## Get the game process's user data directory.
## OS.get_user_data_dir() is cached at editor startup and won't reflect
## project name changes made to project.godot while the editor is running.
## The game process reads the name from disk, so we must do the same.
func get_game_user_dir() -> String:
var cached_dir := OS.get_user_data_dir()
var cfg := ConfigFile.new()
var err := cfg.load(ProjectSettings.globalize_path("res://project.godot"))
if err != OK:
return cached_dir
# When use_custom_user_dir=true, editor and game share the same dir
# (OS.get_user_data_dir() already resolves to the custom path).
if cfg.get_value("application", "config/use_custom_user_dir", false):
return cached_dir
var disk_name = cfg.get_value("application", "config/name", "")
if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty():
return cached_dir
# Sanitize exactly like Godot does when computing the default user dir
# (core/config/project_settings.cpp ProjectSettings::_init).
var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_")
if sanitized.is_empty():
return cached_dir
var base_dir := cached_dir.get_base_dir()
var game_dir := base_dir.path_join(sanitized)
# Ensure the directory exists (game may not have created it yet)
if not DirAccess.dir_exists_absolute(game_dir):
DirAccess.make_dir_recursive_absolute(game_dir)
return game_dir
## Get EditorInterface
func get_editor() -> EditorInterface:
return editor_plugin.get_editor_interface()
## Get the edited scene root
func get_edited_root() -> Node:
return EditorInterface.get_edited_scene_root()
## Get UndoRedo
func get_undo_redo() -> EditorUndoRedoManager:
return editor_plugin.get_undo_redo()
func normalize_project_path(path: String) -> String:
if path.is_empty():
return ""
if path.begins_with("res://") or path.begins_with("user://"):
return path.simplify_path()
return ProjectSettings.localize_path(path).simplify_path()
func is_scene_resource_path(path: String) -> bool:
var ext := path.get_extension().to_lower()
return ext == "tscn" or ext == "scn"
func get_open_scene_paths() -> Array[String]:
var paths: Array[String] = []
var open_scenes: PackedStringArray = EditorInterface.get_open_scenes()
for scene_path: String in open_scenes:
var normalized := normalize_project_path(scene_path)
if not normalized.is_empty() and normalized not in paths:
paths.append(normalized)
var root := get_edited_root()
if root != null and not root.scene_file_path.is_empty():
var active_path := normalize_project_path(root.scene_file_path)
if active_path not in paths:
paths.append(active_path)
return paths
func is_scene_path_open(path: String) -> bool:
var normalized := normalize_project_path(path)
if normalized.is_empty():
return false
return normalized in get_open_scene_paths()
func is_active_scene_path(path: String) -> bool:
var root := get_edited_root()
if root == null:
return false
return normalize_project_path(root.scene_file_path) == normalize_project_path(path)
func guard_offline_scene_save(path: String) -> Dictionary:
if is_scene_resource_path(path) and is_scene_path_open(path):
return error_conflict(
"Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path),
{
"path": normalize_project_path(path),
"open_scenes": get_open_scene_paths(),
"suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.",
}
)
return {}
func is_shader_resource_path(path: String) -> bool:
var ext := path.get_extension().to_lower()
return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader"
func is_text_resource_open_in_script_editor(path: String) -> bool:
var target := normalize_project_path(path)
if target.is_empty():
return false
if is_shader_resource_path(target) and ResourceLoader.has_cached(target):
return true
var script_editor := EditorInterface.get_script_editor()
if script_editor == null:
return false
for open_resource in script_editor.get_open_scripts():
if open_resource is Resource:
var resource_path := normalize_project_path((open_resource as Resource).resource_path)
if resource_path == target:
return true
return false
func guard_text_resource_write(path: String, force: bool) -> Dictionary:
if not force and is_text_resource_open_in_script_editor(path):
return error_conflict(
"Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path),
{
"path": normalize_project_path(path),
"suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.",
}
)
return {}
func mark_current_scene_unsaved() -> void:
if EditorInterface.has_method("mark_scene_as_unsaved"):
EditorInterface.mark_scene_as_unsaved()
func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_method(parent, "add_child", child)
undo_redo.add_do_method(child, "set_owner", root)
undo_redo.add_do_reference(child)
undo_redo.add_undo_method(parent, "remove_child", child)
undo_redo.commit_action()
func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void:
var old_value: Variant = target.get(property)
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_property(target, property, new_value)
if new_value is Resource:
undo_redo.add_do_reference(new_value)
undo_redo.add_undo_property(target, property, old_value)
if old_value is Resource:
undo_redo.add_undo_reference(old_value)
undo_redo.commit_action()
## Find node by path in edited scene
func find_node_by_path(node_path: String) -> Node:
var root := get_edited_root()
if root == null:
return null
if node_path == "." or node_path == root.name:
return root
# Try relative from root
if root.has_node(node_path):
return root.get_node(node_path)
# Try with root name prefix stripped
if node_path.begins_with(root.name + "/"):
var rel := node_path.substr(root.name.length() + 1)
if root.has_node(rel):
return root.get_node(rel)
return null

View File

@@ -0,0 +1,436 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"find_nodes_by_type": _find_nodes_by_type,
"find_signal_connections": _find_signal_connections,
"batch_set_property": _batch_set_property,
"batch_add_nodes": _batch_add_nodes,
"find_node_references": _find_node_references,
"get_scene_dependencies": _get_scene_dependencies,
"cross_scene_set_property": _cross_scene_set_property,
}
func _find_nodes_by_type(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var recursive: bool = optional_bool(params, "recursive", true)
var matches: Array = []
_search_by_type(root, type_name, recursive, matches)
return success({"type": type_name, "matches": matches, "count": matches.size()})
func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
var root := get_edited_root()
matches.append({
"name": node.name,
"path": str(root.get_path_to(node)),
"type": node.get_class(),
})
if recursive:
for child in node.get_children():
_search_by_type(child, type_name, recursive, matches)
func _find_signal_connections(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var signal_filter: String = optional_string(params, "signal_name", "")
var node_filter: String = optional_string(params, "node_path", "")
var connections: Array = []
_collect_signals(root, root, signal_filter, node_filter, connections)
return success({"connections": connections, "count": connections.size()})
func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void:
var node_path := str(root.get_path_to(node))
if node_filter.is_empty() or node_path.contains(node_filter):
for sig_info in node.get_signal_list():
var sig_name: String = sig_info["name"]
if not signal_filter.is_empty() and not sig_name.contains(signal_filter):
continue
for conn in node.get_signal_connection_list(sig_name):
connections.append({
"source": node_path,
"signal": sig_name,
"target": str(root.get_path_to(conn["callable"].get_object())),
"method": conn["callable"].get_method(),
})
for child in node.get_children():
_collect_signals(child, root, signal_filter, node_filter, connections)
func _batch_set_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Parse value string
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var root := get_edited_root()
if root == null:
return error_no_scene()
var affected: Array = []
var changes: Array = []
_batch_collect_property_changes(root, root, type_name, property, value, affected, changes)
if not changes.is_empty():
_apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property)
return success({"property": property, "affected": affected, "count": affected.size()})
func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
if property in node:
affected.append(str(root.get_path_to(node)))
changes.append({
"node": node,
"old_value": node.get(property),
"new_value": value,
})
for child in node.get_children():
_batch_collect_property_changes(child, root, type_name, property, value, affected, changes)
func _batch_add_nodes(params: Dictionary) -> Dictionary:
if not params.has("nodes") or not params["nodes"] is Array:
return error_invalid_params("Missing required parameter: nodes (Array)")
var nodes_data: Array = params["nodes"]
if nodes_data.is_empty():
return error_invalid_params("nodes array is empty")
var root := get_edited_root()
if root == null:
return error_no_scene()
var created: Array = []
var errors: Array = []
for i: int in nodes_data.size():
var entry: Dictionary = nodes_data[i]
if not entry.has("type") or not entry["type"] is String:
errors.append({"index": i, "error": "Missing or invalid 'type'"})
continue
var type: String = entry["type"]
if not ClassDB.class_exists(type):
errors.append({"index": i, "error": "Unknown node type: %s" % type})
continue
var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "."
var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else ""
var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {}
var parent := find_node_by_path(parent_path)
if parent == null:
errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path})
continue
var node: Node = ClassDB.instantiate(type)
if not node_name.is_empty():
node.name = node_name
for prop_name: String in properties:
var prop_exists := false
for prop in node.get_property_list():
if prop["name"] == prop_name:
prop_exists = true
break
if prop_exists:
var current: Variant = node.get(prop_name)
var target_type := typeof(current)
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type)
created.append({
"index": i,
"type": type,
"name": str(node.name),
"parent": parent_path,
"node_path": str(root.get_path_to(node)),
})
var result := {"created": created, "count": created.size()}
if not errors.is_empty():
result["errors"] = errors
return success(result)
func _find_node_references(params: Dictionary) -> Dictionary:
var result := require_string(params, "pattern")
if result[1] != null:
return result[1]
var pattern: String = result[0]
# Search through all .tscn and .gd files for references
var matches: Array = []
_search_files_for_pattern("res://", pattern, matches, 100)
return success({"pattern": pattern, "matches": matches, "count": matches.size()})
func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_search_files_for_pattern(full_path, pattern, matches, max_results)
elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]:
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
if content.contains(pattern):
# Find line numbers
var lines := content.split("\n")
var line_matches: Array = []
for i in lines.size():
if lines[i].contains(pattern):
line_matches.append(i + 1)
if line_matches.size() >= 5:
break
matches.append({
"file": full_path,
"lines": line_matches,
})
file_name = dir.get_next()
dir.list_dir_end()
func _cross_scene_set_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type_name: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Parse value string
if value is String:
var expr := Expression.new()
if expr.parse(value) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
var path_filter: String = optional_string(params, "path_filter", "res://")
var exclude_addons: bool = optional_bool(params, "exclude_addons", true)
var force: bool = optional_bool(params, "force", false)
var dry_run: bool = optional_bool(params, "dry_run", not force)
if not dry_run and not force:
return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false")
var scenes_affected: Array = []
var skipped_open_scenes: Array = []
var total_nodes: int = 0
var scene_files: Array = []
_collect_scene_files(path_filter, scene_files, exclude_addons)
for scene_path: String in scene_files:
var normalized_scene_path := normalize_project_path(scene_path)
if is_scene_path_open(normalized_scene_path):
if is_active_scene_path(normalized_scene_path) and force and not dry_run:
var root := get_edited_root()
var live_changes: Array = []
var live_affected_nodes: Array = []
_cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes)
if not live_changes.is_empty():
_apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property)
scenes_affected.append({
"scene": normalized_scene_path,
"nodes": live_affected_nodes,
"count": live_affected_nodes.size(),
"mode": "live_open_scene",
})
total_nodes += live_affected_nodes.size()
else:
var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene"
skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason})
continue
var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene
if packed == null:
continue
var instance: Node = packed.instantiate()
if instance == null:
continue
var affected_nodes: Array = []
var changes: Array = []
_cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes)
if not changes.is_empty():
if not dry_run:
var guard := guard_offline_scene_save(normalized_scene_path)
if not guard.is_empty():
instance.free()
return guard
for change: Dictionary in changes:
(change["node"] as Node).set(property, value)
# Pack and save
var new_packed := PackedScene.new()
var pack_err := new_packed.pack(instance)
if pack_err != OK:
instance.free()
return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)])
var save_err := ResourceSaver.save(new_packed, normalized_scene_path)
if save_err != OK:
instance.free()
return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)])
scenes_affected.append({
"scene": normalized_scene_path,
"nodes": affected_nodes,
"count": affected_nodes.size(),
"mode": "dry_run" if dry_run else "offline_saved",
})
total_nodes += affected_nodes.size()
instance.free()
# Rescan filesystem so editor picks up changes
if not scenes_affected.is_empty():
EditorInterface.get_resource_filesystem().scan()
return success({
"type": type_name,
"property": property,
"dry_run": dry_run,
"force": force,
"scenes_affected": scenes_affected,
"skipped_open_scenes": skipped_open_scenes,
"total_scenes": scenes_affected.size(),
"total_nodes": total_nodes,
"message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.",
})
func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if exclude_addons and file_name == "addons":
file_name = dir.get_next()
continue
_collect_scene_files(full_path, files, exclude_addons)
elif file_name.get_extension() == "tscn":
files.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
if node.is_class(type_name) or node.get_class() == type_name:
if property in node:
affected.append(str(root.get_path_to(node)))
changes.append({
"node": node,
"old_value": node.get(property),
"new_value": value,
})
for child in node.get_children():
_cross_scene_collect_changes(child, root, type_name, property, value, affected, changes)
func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
for change: Dictionary in changes:
var node: Node = change["node"]
undo_redo.add_do_property(node, property, change["new_value"])
undo_redo.add_undo_property(node, property, change["old_value"])
undo_redo.commit_action()
func _get_scene_dependencies(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("File '%s'" % path)
var deps := ResourceLoader.get_dependencies(path)
var dependencies: Array = []
for dep: String in deps:
# Format: "path::type"
var parts := dep.split("::")
dependencies.append({
"path": parts[0] if parts.size() > 0 else dep,
"type": parts[2] if parts.size() > 2 else "",
})
return success({"path": path, "dependencies": dependencies, "count": dependencies.size()})

View File

@@ -0,0 +1,682 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_editor_errors": _get_editor_errors,
"get_output_log": _get_output_log,
"get_editor_screenshot": _get_editor_screenshot,
"get_game_screenshot": _get_game_screenshot,
"execute_editor_script": _execute_editor_script,
"clear_output": _clear_output,
"reload_plugin": _reload_plugin,
"reload_project": _reload_project,
"get_signals": _get_signals,
"compare_screenshots": _compare_screenshots,
"set_auto_dismiss": _set_auto_dismiss,
"get_editor_camera": _get_editor_camera,
"set_editor_camera": _set_editor_camera,
}
func _get_editor_errors(params: Dictionary) -> Dictionary:
var errors: Array = []
var max_lines: int = optional_int(params, "max_lines", 50)
var base: Control = get_editor().get_base_control()
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
# This captures runtime errors, warnings, and print output
var editor_log: Node = base.find_child("Output", true, false)
if editor_log:
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl:
var content: String = rtl.get_parsed_text()
var lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
errors.append(line.strip_edges())
# 2. Check the script editor for compile errors (red background lines)
# These don't appear in the Output panel
var script_errors: Array = []
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
if script_editor:
var current_script: Script = script_editor.get_current_script()
var ce: CodeEdit = _find_code_edit(script_editor)
if ce and current_script:
var script_path: String = current_script.resource_path
for i in range(ce.get_line_count()):
var bg: Color = ce.get_line_background_color(i)
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
var line_text: String = ce.get_line(i).strip_edges()
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
# Each open script editor has a VSplitContainer with two RichTextLabels:
# child[1] = warnings panel, child[2] = errors panel
var analyzer_errors: Array = []
if script_editor:
var open_editors: Array = script_editor.get_open_script_editors()
var open_scripts: Array = script_editor.get_open_scripts()
for ei in range(open_editors.size()):
var editor_node: Node = open_editors[ei]
var script_path: String = ""
if ei < open_scripts.size() and open_scripts[ei] != null:
script_path = (open_scripts[ei] as Resource).resource_path
var vsplit: VSplitContainer = null
for c in editor_node.get_children():
if c is VSplitContainer:
vsplit = c as VSplitContainer
break
if vsplit == null:
continue
var children: Array = vsplit.get_children()
# child[1] = warnings panel (RichTextLabel)
if children.size() > 1 and children[1] is RichTextLabel:
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty() or stripped == "[Ignore]":
continue
# Remove leading "[Ignore]" prefix from warning lines
stripped = stripped.trim_prefix("[Ignore]")
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
analyzer_errors.append(prefix + stripped)
# child[2] = errors panel (RichTextLabel)
if children.size() > 2 and children[2] is RichTextLabel:
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty():
continue
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
analyzer_errors.append(prefix + stripped)
# 4. Read from the debugger Errors tab (runtime errors/warnings)
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
var debugger_errors: Array = []
var base2: Control = get_editor().get_base_control()
if base2:
var queue: Array[Node] = [base2]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
# Find TabContainer inside the debugger
for child in node.get_children():
if child is TabContainer:
var tab_container := child as TabContainer
for tab_idx in range(tab_container.get_tab_count()):
var tab_control: Control = tab_container.get_tab_control(tab_idx)
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
# Find Tree inside the Errors tab
for vchild in tab_control.get_children():
if vchild is Tree:
var tree := vchild as Tree
var root_item: TreeItem = tree.get_root()
if root_item:
var item: TreeItem = root_item.get_first_child()
while item:
var col0: String = item.get_text(0).strip_edges()
var col1: String = item.get_text(1).strip_edges()
if not col0.is_empty() or not col1.is_empty():
var msg: String = col0
if not col1.is_empty():
msg += " " + col1 if not msg.is_empty() else col1
debugger_errors.append("DEBUGGER: " + msg)
# Also check child items (expanded error details)
var sub: TreeItem = item.get_first_child()
while sub:
var sub0: String = sub.get_text(0).strip_edges()
var sub1: String = sub.get_text(1).strip_edges()
if not sub0.is_empty() or not sub1.is_empty():
var sub_msg: String = sub0
if not sub1.is_empty():
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
debugger_errors.append("DEBUGGER: " + sub_msg)
sub = sub.get_next()
item = item.get_next()
break # Found Errors tab, stop searching tabs
break # Found TabContainer, stop searching debugger children
break # Found ScriptEditorDebugger, stop BFS
for child in node.get_children():
queue.append(child)
# Fallback: read from log file if Output panel not accessible
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
var log_path := "user://logs/godot.log"
if FileAccess.file_exists(log_path):
var file := FileAccess.open(log_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
errors.append(line.strip_edges())
errors.append_array(script_errors)
errors.append_array(analyzer_errors)
errors.append_array(debugger_errors)
return success({"errors": errors, "count": errors.size()})
func _get_output_log(params: Dictionary) -> Dictionary:
var max_lines: int = optional_int(params, "max_lines", 100)
var filter: String = optional_string(params, "filter", "")
var base: Control = get_editor().get_base_control()
var editor_log: Node = base.find_child("Output", true, false)
if editor_log == null:
# Fallback: read from log file
var log_path := "user://logs/godot.log"
if not FileAccess.file_exists(log_path):
return error_internal("Output panel not found and no log file available")
var file := FileAccess.open(log_path, FileAccess.READ)
if file == null:
return error_internal("Cannot read log file")
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, lines.size()):
var line: String = lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl == null:
return error_internal("Could not find RichTextLabel in Output panel")
var content: String = rtl.get_parsed_text()
var all_lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, all_lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, all_lines.size()):
var line: String = all_lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
if depth > 8:
return null
if node is CodeEdit:
return node as CodeEdit
for child in node.get_children():
var found: CodeEdit = _find_code_edit(child, depth + 1)
if found:
return found
return null
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
if depth > 6:
return null
if node is RichTextLabel:
return node as RichTextLabel
for child in node.get_children():
var found: RichTextLabel = _find_rtl(child, depth + 1)
if found:
return found
return null
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
# Capture the editor's main viewport - no await to avoid timeout
var base_control: Control = get_editor().get_base_control()
if base_control == null:
return error_internal("Could not access editor base control")
var viewport: Viewport = base_control.get_viewport()
if viewport == null:
return error_internal("Could not access editor viewport")
var texture: ViewportTexture = viewport.get_texture()
if texture == null:
return error_internal("Could not get viewport texture")
var image: Image = texture.get_image()
if image == null:
return error_internal("Could not get image from viewport")
var save_path: String = params.get("save_path", "")
if save_path != "":
var abs_path := _resolve_save_path(save_path)
var err := image.save_png(abs_path)
if err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(err))
return success({
"saved_path": save_path,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _get_game_screenshot(params: Dictionary) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
# Communicate with the game process via file system
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_screenshot_request"
var screenshot_path := user_dir + "/mcp_screenshot.png"
# Clean up any stale screenshot file
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)
# Create the request file to signal the game process
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create screenshot request file")
req.close()
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
var attempts := 30
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(screenshot_path):
break
attempts -= 1
if not FileAccess.file_exists(screenshot_path):
# Clean up request file if it still exists
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Screenshot timed out", {
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
})
# Load the PNG file
var image := Image.new()
var err := image.load(screenshot_path)
if err != OK:
DirAccess.remove_absolute(screenshot_path)
return error_internal("Failed to load screenshot: %s" % error_string(err))
# Clean up temp file
DirAccess.remove_absolute(screenshot_path)
var save_path_param: String = params.get("save_path", "")
if save_path_param != "":
var abs_path := _resolve_save_path(save_path_param)
var save_err := image.save_png(abs_path)
if save_err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
return success({
"saved_path": save_path_param,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _resolve_save_path(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return ProjectSettings.globalize_path(path)
return path
func _execute_editor_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "code")
if result[1] != null:
return result[1]
var code: String = result[0]
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
if not unsafe_guard.is_empty():
return unsafe_guard
# Wrap user code in a @tool script
var wrapped_code := """@tool
extends Node
var _mcp_output: Array = []
func _mcp_print(value: Variant) -> void:
_mcp_output.append(str(value))
func run() -> Variant:
# User code begins
%s
# User code ends
return _mcp_output
""" % _indent_code(code)
# Create a temporary script
var script := GDScript.new()
script.source_code = wrapped_code
var err := script.reload()
if err != OK:
return error(-32002, "Script compilation failed", {
"error": error_string(err),
"code": wrapped_code,
})
# Create temp node and execute
var temp_node := Node.new()
temp_node.set_script(script)
add_child(temp_node)
var output: Variant = null
# Execute with error handling
if temp_node.has_method("run"):
output = temp_node.run()
var mcp_output: Array = []
var raw_output: Variant = temp_node.get("_mcp_output")
if raw_output is Array:
mcp_output = raw_output
# Cleanup
temp_node.queue_free()
return success({
"output": mcp_output,
"return_value": str(output) if output != null else null,
})
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
if allow_unsafe_editor_io:
return {}
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
var unsafe_patterns: Array[String] = []
if compact.contains("ResourceSaver.save("):
unsafe_patterns.append("ResourceSaver.save")
if compact.contains("ProjectSettings.save("):
unsafe_patterns.append("ProjectSettings.save")
if compact.contains("ConfigFile.save("):
unsafe_patterns.append("ConfigFile.save")
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
unsafe_patterns.append("FileAccess.open WRITE")
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
unsafe_patterns.append("DirAccess filesystem mutation")
if unsafe_patterns.is_empty():
return {}
return error_conflict(
"Refusing to execute editor script with direct file/resource write APIs",
{
"unsafe_patterns": unsafe_patterns,
"open_scenes": get_open_scene_paths(),
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
}
)
func _contains_any(value: String, needles: Array[String]) -> bool:
for needle: String in needles:
if value.contains(needle):
return true
return false
func _indent_code(code: String) -> String:
var lines := code.split("\n")
var indented: PackedStringArray = []
for line in lines:
indented.append("\t" + line)
return "\n".join(indented)
func _clear_output(params: Dictionary) -> Dictionary:
print("\n".repeat(50))
return success({"cleared": true})
func _reload_plugin(params: Dictionary) -> Dictionary:
# Disable and re-enable this plugin to reload all scripts
var plugin_name := "godot_mcp"
var ei := get_editor()
# Send success BEFORE reloading (connection will briefly drop)
# Use call_deferred so the response is sent first
_deferred_reload_plugin.call_deferred(ei, plugin_name)
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
ei.set_plugin_enabled(plugin_name, false)
ei.set_plugin_enabled(plugin_name, true)
print("[MCP] Plugin reloaded")
func _reload_project(params: Dictionary) -> Dictionary:
# Rescan filesystem and reload changed scripts
var ei := get_editor()
ei.get_resource_filesystem().scan()
return success({"reloaded": true, "message": "Filesystem rescanned."})
func _get_signals(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path)
var signals: Array = []
for sig in node.get_signal_list():
var sig_info: Dictionary = {
"name": sig["name"],
"args": [],
}
for arg in sig["args"]:
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
# Get connections for this signal
var connections: Array = []
for conn in node.get_signal_connection_list(sig["name"]):
connections.append({
"target": str(root.get_path_to(conn["callable"].get_object())),
"method": conn["callable"].get_method(),
})
sig_info["connections"] = connections
signals.append(sig_info)
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"signals": signals,
"count": signals.size(),
})
func _load_image_from_param(value: String, label: String) -> Array:
## Returns [Image, null] on success or [null, error_dict] on failure.
## Accepts a file path (res://, user://) or raw base64 PNG data.
var img := Image.new()
if value.begins_with("res://") or value.begins_with("user://"):
var err := img.load(value)
if err != OK:
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
return [img, null]
# Treat as base64 PNG
var buf := Marshalls.base64_to_raw(value)
var err := img.load_png_from_buffer(buf)
if err != OK:
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
return [img, null]
func _compare_screenshots(params: Dictionary) -> Dictionary:
var result := require_string(params, "image_a")
if result[1] != null:
return result[1]
var image_a_value: String = result[0]
var result2 := require_string(params, "image_b")
if result2[1] != null:
return result2[1]
var image_b_value: String = result2[0]
var threshold: int = optional_int(params, "threshold", 10)
# Load images (from path or base64)
var load_a := _load_image_from_param(image_a_value, "image_a")
if load_a[1] != null:
return load_a[1]
var img_a: Image = load_a[0]
var load_b := _load_image_from_param(image_b_value, "image_b")
if load_b[1] != null:
return load_b[1]
var img_b: Image = load_b[0]
if img_a.get_size() != img_b.get_size():
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
var width := img_a.get_width()
var height := img_a.get_height()
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
var changed_pixels: int = 0
var total_pixels: int = width * height
for y in height:
for x in width:
var ca: Color = img_a.get_pixel(x, y)
var cb: Color = img_b.get_pixel(x, y)
var dr := absi(int(ca.r8) - int(cb.r8))
var dg := absi(int(ca.g8) - int(cb.g8))
var db := absi(int(ca.b8) - int(cb.b8))
var max_diff := maxi(dr, maxi(dg, db))
if max_diff > threshold:
changed_pixels += 1
# Red highlight for changed pixels
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
else:
# Dim version of original
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
var identical: bool = changed_pixels == 0
# Encode diff image
var diff_png := diff_image.save_png_to_buffer()
var diff_base64 := Marshalls.raw_to_base64(diff_png)
return success({
"identical": identical,
"changed_pixels": changed_pixels,
"total_pixels": total_pixels,
"diff_percentage": snappedf(diff_percentage, 0.01),
"threshold": threshold,
"width": width,
"height": height,
"diff_image_base64": diff_base64,
})
func _get_editor_camera(_params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
"near": cam.near,
"far": cam.far,
})
func _set_editor_camera(params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
# Set position
if params.has("position"):
var p: Dictionary = params["position"]
cam.global_position = Vector3(
float(p.get("x", cam.global_position.x)),
float(p.get("y", cam.global_position.y)),
float(p.get("z", cam.global_position.z)),
)
# Set rotation
if params.has("rotation_degrees"):
var r: Dictionary = params["rotation_degrees"]
cam.rotation_degrees = Vector3(
float(r.get("x", cam.rotation_degrees.x)),
float(r.get("y", cam.rotation_degrees.y)),
float(r.get("z", cam.rotation_degrees.z)),
)
# Look at target (overrides rotation if set)
if params.has("look_at"):
var t: Dictionary = params["look_at"]
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
# Set FOV
if params.has("fov"):
cam.fov = float(params["fov"])
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
})
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
var enabled: bool = params.get("enabled", true)
editor_plugin.auto_dismiss_dialogs = enabled
return success({
"auto_dismiss": enabled,
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
})

View File

@@ -0,0 +1,117 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_export_presets": _list_export_presets,
"export_project": _export_project,
"get_export_info": _get_export_info,
}
func _list_export_presets(params: Dictionary) -> Dictionary:
# Read export_presets.cfg
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return success({"presets": [], "count": 0, "message": "No export_presets.cfg found"})
var cfg := ConfigFile.new()
var err := cfg.load(presets_path)
if err != OK:
return error_internal("Failed to read export_presets.cfg: %s" % error_string(err))
var presets: Array = []
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
presets.append({
"index": idx,
"name": cfg.get_value(section, "name", ""),
"platform": cfg.get_value(section, "platform", ""),
"runnable": cfg.get_value(section, "runnable", false),
"export_path": cfg.get_value(section, "export_path", ""),
})
idx += 1
return success({"presets": presets, "count": presets.size()})
func _export_project(params: Dictionary) -> Dictionary:
var preset_index: int = optional_int(params, "preset_index", -1)
var preset_name: String = optional_string(params, "preset_name", "")
var debug: bool = optional_bool(params, "debug", true)
# Find preset
var presets_path := "res://export_presets.cfg"
if not FileAccess.file_exists(presets_path):
return error(-32000, "No export_presets.cfg found. Configure exports in Project > Export first.")
var cfg := ConfigFile.new()
var err := cfg.load(presets_path)
if err != OK:
return error_internal("Failed to read export_presets.cfg")
# Find by name or index
var target_section := ""
var target_name := ""
var target_path := ""
if not preset_name.is_empty():
var idx := 0
while cfg.has_section("preset.%d" % idx):
var section := "preset.%d" % idx
if cfg.get_value(section, "name", "") == preset_name:
target_section = section
target_name = preset_name
target_path = cfg.get_value(section, "export_path", "")
break
idx += 1
elif preset_index >= 0:
var section := "preset.%d" % preset_index
if cfg.has_section(section):
target_section = section
target_name = cfg.get_value(section, "name", "")
target_path = cfg.get_value(section, "export_path", "")
if target_section.is_empty():
return error_not_found("Export preset")
if target_path.is_empty():
return error(-32000, "Export path not configured for preset '%s'" % target_name)
# Use EditorExportPlatform via command line
# We can't directly call export from the plugin, so we return the command to run
var godot_path := OS.get_executable_path()
var project_path := ProjectSettings.globalize_path("res://")
var export_path := ProjectSettings.globalize_path(target_path) if target_path.begins_with("res://") else target_path
var flag := "--export-debug" if debug else "--export-release"
var command := '"%s" --headless --path "%s" %s "%s"' % [godot_path, project_path, flag, target_name]
return success({
"preset": target_name,
"export_path": export_path,
"debug": debug,
"command": command,
"message": "Run the command above to export. Direct export from editor plugin is not supported in Godot 4.",
})
func _get_export_info(params: Dictionary) -> Dictionary:
# General export-related project info
var info := {}
# Check if export_presets.cfg exists
info["has_export_presets"] = FileAccess.file_exists("res://export_presets.cfg")
# Get Godot executable path (useful for command-line exports)
info["godot_executable"] = OS.get_executable_path()
info["project_path"] = ProjectSettings.globalize_path("res://")
# Check for common export templates
var templates_path := OS.get_data_dir().path_join("export_templates")
info["templates_dir"] = templates_path
info["templates_installed"] = DirAccess.dir_exists_absolute(templates_path)
return success(info)

View File

@@ -0,0 +1,162 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const COMMANDS_PATH := "user://mcp_input_commands"
func get_commands() -> Dictionary:
return {
"simulate_key": _simulate_key,
"simulate_mouse_click": _simulate_mouse_click,
"simulate_mouse_move": _simulate_mouse_move,
"simulate_action": _simulate_action,
"simulate_sequence": _simulate_sequence,
}
func _simulate_key(params: Dictionary) -> Dictionary:
var result := require_string(params, "keycode")
if result[1] != null:
return result[1]
var keycode: String = result[0]
var pressed: bool = optional_bool(params, "pressed", true)
var shift: bool = optional_bool(params, "shift", false)
var ctrl: bool = optional_bool(params, "ctrl", false)
var alt: bool = optional_bool(params, "alt", false)
var event := {
"type": "key",
"keycode": keycode,
"pressed": pressed,
"shift": shift,
"ctrl": ctrl,
"alt": alt,
}
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_mouse_click(params: Dictionary) -> Dictionary:
var button: int = optional_int(params, "button", 1) # MOUSE_BUTTON_LEFT
var pressed: bool = optional_bool(params, "pressed", true)
var double_click: bool = optional_bool(params, "double_click", false)
var auto_release: bool = optional_bool(params, "auto_release", true)
var x: float = float(params.get("x", 0))
var y: float = float(params.get("y", 0))
var press_event := {
"type": "mouse_button",
"button": button,
"pressed": pressed,
"double_click": double_click,
"position": {"x": x, "y": y},
}
# Auto-release: send press + release in sequence so UI buttons actually fire
if pressed and auto_release:
var release_event := press_event.duplicate()
release_event["pressed"] = false
var sequence_data := {
"sequence_events": [press_event, release_event],
"frame_delay": 1,
}
var json := JSON.stringify(sequence_data)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
file.store_string(json)
file.close()
return success({"sent": true, "event": press_event, "auto_release": true})
_write_commands([press_event])
return success({"sent": true, "event": press_event})
func _simulate_mouse_move(params: Dictionary) -> Dictionary:
var x: float = float(params.get("x", 0))
var y: float = float(params.get("y", 0))
var rel_x: float = float(params.get("relative_x", 0))
var rel_y: float = float(params.get("relative_y", 0))
var button_mask: int = optional_int(params, "button_mask", 0)
var unhandled_explicit: bool = params.has("unhandled")
var unhandled: bool = optional_bool(params, "unhandled", false)
var event := {
"type": "mouse_motion",
"position": {"x": x, "y": y},
"relative": {"x": rel_x, "y": rel_y},
"button_mask": button_mask,
}
# Auto-enable unhandled for drag motions (camera-pan use case) ONLY when
# the caller did NOT explicitly pass an "unhandled" key. If they passed
# one — true or false — honor it. This lets UI drag-and-drop tests opt
# back into normal GUI dispatch by passing unhandled: false explicitly.
if unhandled_explicit:
event["unhandled"] = unhandled
elif button_mask > 0:
event["unhandled"] = true
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_action(params: Dictionary) -> Dictionary:
var result := require_string(params, "action")
if result[1] != null:
return result[1]
var action_name: String = result[0]
var pressed: bool = optional_bool(params, "pressed", true)
var strength: float = float(params.get("strength", 1.0))
var event := {
"type": "action",
"action": action_name,
"pressed": pressed,
"strength": strength,
}
_write_commands([event])
return success({"sent": true, "event": event})
func _simulate_sequence(params: Dictionary) -> Dictionary:
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("Missing required parameter: events (Array)")
var events: Array = params["events"]
if events.is_empty():
return error_invalid_params("Events array is empty")
var frame_delay: int = optional_int(params, "frame_delay", 1)
for event_data: Dictionary in events:
if not event_data.has("type") or (event_data["type"] as String).is_empty():
return error_invalid_params("Invalid event in sequence: %s" % str(event_data))
if frame_delay <= 0:
# All events in one frame - write as plain array
_write_commands(events)
else:
# Sequence with frame delay - game side handles timing
var sequence_data := {
"sequence_events": events,
"frame_delay": frame_delay,
}
var json := JSON.stringify(sequence_data)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
file.store_string(json)
file.close()
return success({"sent": true, "event_count": events.size(), "frame_delay": frame_delay})
func _write_commands(events: Array) -> void:
var json := JSON.stringify(events)
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
if file == null:
push_error("[MCP Input] Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
return
file.store_string(json)
file.close()

View File

@@ -0,0 +1,151 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_input_actions": _get_input_actions,
"set_input_action": _set_input_action,
}
func _get_input_actions(params: Dictionary) -> Dictionary:
var filter: String = optional_string(params, "filter", "")
var include_builtin: bool = optional_bool(params, "include_builtin", false)
var actions: Dictionary = {}
for action: StringName in InputMap.get_actions():
var action_str := str(action)
# Skip built-in UI actions unless requested
if not include_builtin and action_str.begins_with("ui_"):
continue
# Apply filter
if not filter.is_empty() and not action_str.contains(filter):
continue
var events: Array = []
for event: InputEvent in InputMap.action_get_events(action):
events.append(_serialize_event(event))
actions[action_str] = {
"deadzone": InputMap.action_get_deadzone(action),
"events": events,
}
return success({"actions": actions, "count": actions.size()})
func _set_input_action(params: Dictionary) -> Dictionary:
var result := require_string(params, "action")
if result[1] != null:
return result[1]
var action_name: String = result[0]
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("'events' array is required")
var event_defs: Array = params["events"]
var deadzone: float = float(params.get("deadzone", 0.5))
# Build the events array
var events: Array[InputEvent] = []
for event_def in event_defs:
if not event_def is Dictionary:
continue
var event := _parse_event(event_def)
if event != null:
events.append(event)
# Save to ProjectSettings
var setting_value := {
"deadzone": deadzone,
"events": events,
}
ProjectSettings.set_setting("input/" + action_name, setting_value)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
# Also update the runtime InputMap
if not InputMap.has_action(action_name):
InputMap.add_action(action_name, deadzone)
else:
InputMap.action_set_deadzone(action_name, deadzone)
InputMap.action_erase_events(action_name)
for event in events:
InputMap.action_add_event(action_name, event)
return success({
"action": action_name,
"deadzone": deadzone,
"events_count": events.size(),
"saved": true,
})
func _serialize_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key_event: InputEventKey = event
var info := {
"type": "key",
"keycode": OS.get_keycode_string(key_event.keycode) if key_event.keycode != KEY_NONE else "",
"physical_keycode": OS.get_keycode_string(key_event.physical_keycode) if key_event.physical_keycode != KEY_NONE else "",
}
if key_event.ctrl_pressed: info["ctrl"] = true
if key_event.shift_pressed: info["shift"] = true
if key_event.alt_pressed: info["alt"] = true
if key_event.meta_pressed: info["meta"] = true
return info
elif event is InputEventMouseButton:
var mb_event: InputEventMouseButton = event
return {
"type": "mouse_button",
"button_index": mb_event.button_index,
}
elif event is InputEventJoypadButton:
var jb_event: InputEventJoypadButton = event
return {
"type": "joypad_button",
"button_index": jb_event.button_index,
}
elif event is InputEventJoypadMotion:
var jm_event: InputEventJoypadMotion = event
return {
"type": "joypad_motion",
"axis": jm_event.axis,
"axis_value": jm_event.axis_value,
}
return {"type": event.get_class()}
func _parse_event(def: Dictionary) -> InputEvent:
var type: String = def.get("type", "")
match type:
"key":
var event := InputEventKey.new()
var keycode_str: String = def.get("keycode", "")
if not keycode_str.is_empty():
event.keycode = OS.find_keycode_from_string(keycode_str)
var phys_str: String = def.get("physical_keycode", "")
if not phys_str.is_empty():
event.physical_keycode = OS.find_keycode_from_string(phys_str)
event.ctrl_pressed = def.get("ctrl", false)
event.shift_pressed = def.get("shift", false)
event.alt_pressed = def.get("alt", false)
event.meta_pressed = def.get("meta", false)
return event
"mouse_button":
var event := InputEventMouseButton.new()
event.button_index = int(def.get("button_index", 1))
return event
"joypad_button":
var event := InputEventJoypadButton.new()
event.button_index = int(def.get("button_index", 0))
return event
"joypad_motion":
var event := InputEventJoypadMotion.new()
event.axis = int(def.get("axis", 0))
event.axis_value = float(def.get("axis_value", 1.0))
return event
return null

View File

@@ -0,0 +1,472 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"setup_navigation_region": _setup_navigation_region,
"bake_navigation_mesh": _bake_navigation_mesh,
"setup_navigation_agent": _setup_navigation_agent,
"set_navigation_layers": _set_navigation_layers,
"get_navigation_info": _get_navigation_info,
}
func _is_3d_context(node: Node) -> bool:
if node is Node3D:
return true
if node is Node2D:
return false
# Walk up to detect context
var parent := node.get_parent()
while parent != null:
if parent is Node3D:
return true
if parent is Node2D:
return false
parent = parent.get_parent()
return false
func _setup_navigation_region(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var force_mode: String = optional_string(params, "mode", "auto")
var is_3d: bool
match force_mode:
"2d": is_3d = false
"3d": is_3d = true
_: is_3d = _is_3d_context(node)
if is_3d:
var region := NavigationRegion3D.new()
region.name = optional_string(params, "name", "NavigationRegion3D")
var nav_mesh := NavigationMesh.new()
nav_mesh.agent_radius = float(params.get("agent_radius", 0.5))
nav_mesh.agent_height = float(params.get("agent_height", 1.5))
nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25))
nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0))
nav_mesh.cell_size = float(params.get("cell_size", 0.25))
nav_mesh.cell_height = float(params.get("cell_height", 0.25))
region.navigation_mesh = nav_mesh
if params.has("navigation_layers"):
region.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion3D")
return success({
"node_path": str(region.get_path()),
"type": "NavigationRegion3D",
"agent_radius": nav_mesh.agent_radius,
"agent_height": nav_mesh.agent_height,
"cell_size": nav_mesh.cell_size,
"created": true,
})
else:
var region := NavigationRegion2D.new()
region.name = optional_string(params, "name", "NavigationRegion2D")
var nav_poly := NavigationPolygon.new()
# Set parsed geometry source if available
if params.has("source_geometry_mode"):
var mode_str: String = str(params["source_geometry_mode"])
match mode_str:
"root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN
"groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN
"groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
if params.has("cell_size"):
nav_poly.cell_size = float(params["cell_size"])
if params.has("agent_radius"):
nav_poly.agent_radius = float(params["agent_radius"])
region.navigation_polygon = nav_poly
if params.has("navigation_layers"):
region.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion2D")
return success({
"node_path": str(region.get_path()),
"type": "NavigationRegion2D",
"cell_size": nav_poly.cell_size,
"created": true,
})
func _bake_navigation_mesh(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
if node is NavigationRegion3D:
var region: NavigationRegion3D = node as NavigationRegion3D
if region.navigation_mesh == null:
return error_invalid_params("NavigationRegion3D has no NavigationMesh resource")
region.bake_navigation_mesh()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion3D",
"baked": true,
})
elif node is NavigationRegion2D:
var region: NavigationRegion2D = node as NavigationRegion2D
if region.navigation_polygon == null:
var nav_poly := NavigationPolygon.new()
region.navigation_polygon = nav_poly
# Set outline vertices from params
if params.has("outline"):
var outline_data: Array = params["outline"]
var outline := PackedVector2Array()
for point in outline_data:
if point is Array and point.size() >= 2:
outline.append(Vector2(float(point[0]), float(point[1])))
elif point is Dictionary:
outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0))))
if outline.size() >= 3:
# Clear existing outlines
while region.navigation_polygon.get_outline_count() > 0:
region.navigation_polygon.remove_outline(0)
region.navigation_polygon.add_outline(outline)
region.navigation_polygon.make_polygons_from_outlines()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion2D",
"outline_vertices": outline.size(),
"baked": true,
})
else:
return error_invalid_params("Outline must have at least 3 vertices")
else:
# Try baking from source geometry
region.bake_navigation_polygon()
mark_current_scene_unsaved()
return success({
"node_path": node_path,
"type": "NavigationRegion2D",
"baked": true,
})
return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path)
func _setup_navigation_agent(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var root := get_edited_root()
if root == null:
return error_no_scene()
var force_mode: String = optional_string(params, "mode", "auto")
var is_3d: bool
match force_mode:
"2d": is_3d = false
"3d": is_3d = true
_: is_3d = _is_3d_context(node)
var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D")
if is_3d:
var agent := NavigationAgent3D.new()
agent.name = agent_name
if params.has("path_desired_distance"):
agent.path_desired_distance = float(params["path_desired_distance"])
if params.has("target_desired_distance"):
agent.target_desired_distance = float(params["target_desired_distance"])
if params.has("radius"):
agent.radius = float(params["radius"])
if params.has("neighbor_distance"):
agent.neighbor_distance = float(params["neighbor_distance"])
if params.has("max_neighbors"):
agent.max_neighbors = int(params["max_neighbors"])
if params.has("max_speed"):
agent.max_speed = float(params["max_speed"])
if params.has("avoidance_enabled"):
agent.avoidance_enabled = bool(params["avoidance_enabled"])
if params.has("navigation_layers"):
agent.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent3D")
return success({
"node_path": str(agent.get_path()),
"type": "NavigationAgent3D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
"created": true,
})
else:
var agent := NavigationAgent2D.new()
agent.name = agent_name
if params.has("path_desired_distance"):
agent.path_desired_distance = float(params["path_desired_distance"])
if params.has("target_desired_distance"):
agent.target_desired_distance = float(params["target_desired_distance"])
if params.has("radius"):
agent.radius = float(params["radius"])
if params.has("neighbor_distance"):
agent.neighbor_distance = float(params["neighbor_distance"])
if params.has("max_neighbors"):
agent.max_neighbors = int(params["max_neighbors"])
if params.has("max_speed"):
agent.max_speed = float(params["max_speed"])
if params.has("avoidance_enabled"):
agent.avoidance_enabled = bool(params["avoidance_enabled"])
if params.has("navigation_layers"):
agent.navigation_layers = int(params["navigation_layers"])
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent2D")
return success({
"node_path": str(agent.get_path()),
"type": "NavigationAgent2D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
"created": true,
})
func _set_navigation_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
# Support setting by bitmask value
if params.has("layers"):
var layers_val: int = int(params["layers"])
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": layers_val,
"updated": true,
})
# Support setting individual layer bits by number
if params.has("layer_bits"):
var bits: Array = params["layer_bits"]
var current_layers: int = 0
# Calculate bitmask from layer numbers (1-based)
for bit in bits:
var layer_num: int = int(bit)
if layer_num >= 1 and layer_num <= 32:
current_layers |= (1 << (layer_num - 1))
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": current_layers,
"layer_bits": bits,
"updated": true,
})
# Support named layers from ProjectSettings
if params.has("layer_names"):
var names: Array = params["layer_names"]
var current_layers: int = 0
var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D
var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_"
for i in range(1, 33):
var setting_key: String = prefix + str(i)
if ProjectSettings.has_setting(setting_key):
var layer_name: String = str(ProjectSettings.get_setting(setting_key))
if layer_name in names:
current_layers |= (1 << (i - 1))
if node is NavigationRegion2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationRegion3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent2D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
elif node is NavigationAgent3D:
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
else:
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
return success({
"node_path": node_path,
"navigation_layers": current_layers,
"layer_names": names,
"updated": true,
})
return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)")
func _get_navigation_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var regions: Array = []
var agents: Array = []
_collect_navigation_nodes(node, regions, agents)
# Collect named layers from ProjectSettings
var layer_names_2d: Dictionary = {}
var layer_names_3d: Dictionary = {}
for i in range(1, 33):
var key_2d: String = "layer_names/2d_navigation/layer_" + str(i)
var key_3d: String = "layer_names/3d_navigation/layer_" + str(i)
if ProjectSettings.has_setting(key_2d):
var name_2d: String = str(ProjectSettings.get_setting(key_2d))
if not name_2d.is_empty():
layer_names_2d[i] = name_2d
if ProjectSettings.has_setting(key_3d):
var name_3d: String = str(ProjectSettings.get_setting(key_3d))
if not name_3d.is_empty():
layer_names_3d[i] = name_3d
return success({
"node_path": node_path,
"regions": regions,
"agents": agents,
"region_count": regions.size(),
"agent_count": agents.size(),
"layer_names_2d": layer_names_2d,
"layer_names_3d": layer_names_3d,
})
func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void:
if node is NavigationRegion2D:
var region: NavigationRegion2D = node as NavigationRegion2D
var region_info := {
"path": str(region.get_path()),
"type": "NavigationRegion2D",
"enabled": region.enabled,
"navigation_layers": region.navigation_layers,
"has_polygon": region.navigation_polygon != null,
}
if region.navigation_polygon != null:
var nav_poly: NavigationPolygon = region.navigation_polygon
region_info["outline_count"] = nav_poly.get_outline_count()
region_info["polygon_count"] = nav_poly.get_polygon_count()
region_info["cell_size"] = nav_poly.cell_size
region_info["agent_radius"] = nav_poly.agent_radius
regions.append(region_info)
elif node is NavigationRegion3D:
var region: NavigationRegion3D = node as NavigationRegion3D
var region_info := {
"path": str(region.get_path()),
"type": "NavigationRegion3D",
"enabled": region.enabled,
"navigation_layers": region.navigation_layers,
"has_mesh": region.navigation_mesh != null,
}
if region.navigation_mesh != null:
var nav_mesh: NavigationMesh = region.navigation_mesh
region_info["agent_radius"] = nav_mesh.agent_radius
region_info["agent_height"] = nav_mesh.agent_height
region_info["agent_max_climb"] = nav_mesh.agent_max_climb
region_info["agent_max_slope"] = nav_mesh.agent_max_slope
region_info["cell_size"] = nav_mesh.cell_size
region_info["cell_height"] = nav_mesh.cell_height
regions.append(region_info)
if node is NavigationAgent2D:
var agent: NavigationAgent2D = node as NavigationAgent2D
agents.append({
"path": str(agent.get_path()),
"type": "NavigationAgent2D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"path_desired_distance": agent.path_desired_distance,
"target_desired_distance": agent.target_desired_distance,
"neighbor_distance": agent.neighbor_distance,
"max_neighbors": agent.max_neighbors,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
})
elif node is NavigationAgent3D:
var agent: NavigationAgent3D = node as NavigationAgent3D
agents.append({
"path": str(agent.get_path()),
"type": "NavigationAgent3D",
"radius": agent.radius,
"max_speed": agent.max_speed,
"path_desired_distance": agent.path_desired_distance,
"target_desired_distance": agent.target_desired_distance,
"neighbor_distance": agent.neighbor_distance,
"max_neighbors": agent.max_neighbors,
"avoidance_enabled": agent.avoidance_enabled,
"navigation_layers": agent.navigation_layers,
})
for child in node.get_children():
_collect_navigation_nodes(child, regions, agents)

View File

@@ -0,0 +1,706 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"add_node": _add_node,
"delete_node": _delete_node,
"duplicate_node": _duplicate_node,
"move_node": _move_node,
"update_property": _update_property,
"get_node_properties": _get_node_properties,
"add_resource": _add_resource,
"set_anchor_preset": _set_anchor_preset,
"rename_node": _rename_node,
"connect_signal": _connect_signal,
"disconnect_signal": _disconnect_signal,
"get_node_groups": _get_node_groups,
"set_node_groups": _set_node_groups,
"find_nodes_in_group": _find_nodes_in_group,
}
func _find_script_by_class_name(class_name_str: String) -> Script:
# Search project files for a script with matching class_name
var global_classes: Array = ProjectSettings.get_global_class_list()
for entry: Dictionary in global_classes:
if entry.get("class", "") == class_name_str:
var path: String = entry.get("path", "")
if not path.is_empty():
return load(path) as Script
return null
func _add_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "type")
if result[1] != null:
return result[1]
var type: String = result[0]
var parent_path: String = optional_string(params, "parent_path", ".")
var node_name: String = optional_string(params, "name", "")
var properties: Dictionary = params.get("properties", {})
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
var node: Node
var custom_script: Script = null
if ClassDB.class_exists(type):
node = ClassDB.instantiate(type)
else:
# Try to find a script with matching class_name
custom_script = _find_script_by_class_name(type)
if custom_script == null:
return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type)
var base_type: String = custom_script.get_instance_base_type()
if not ClassDB.class_exists(base_type):
return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type])
node = ClassDB.instantiate(base_type)
node.set_script(custom_script)
if not node_name.is_empty():
node.name = node_name
# Apply properties
for prop_name: String in properties:
var prop_exists := false
for prop in node.get_property_list():
if prop["name"] == prop_name:
prop_exists = true
break
if prop_exists:
var current: Variant = node.get(prop_name)
var target_type := typeof(current)
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add %s" % type)
undo_redo.add_do_method(parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(parent, "remove_child", node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"type": type,
"name": str(node.name),
})
func _delete_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if node == root:
return error_invalid_params("Cannot delete the root node")
var parent := node.get_parent()
var node_name := str(node.name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Delete %s" % node_name)
undo_redo.add_do_method(parent, "remove_child", node)
undo_redo.add_undo_method(parent, "add_child", node)
undo_redo.add_undo_method(node, "set_owner", root)
undo_redo.add_undo_reference(node)
undo_redo.commit_action()
return success({"deleted": node_name})
func _duplicate_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var new_name: String = optional_string(params, "name", "")
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if new_name.is_empty():
new_name = str(node.name) + "_copy"
var dup := node.duplicate()
dup.name = new_name
var parent := node.get_parent()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Duplicate %s" % node.name)
undo_redo.add_do_method(parent, "add_child", dup)
undo_redo.add_do_method(dup, "set_owner", root)
undo_redo.add_do_reference(dup)
undo_redo.add_undo_method(parent, "remove_child", dup)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(dup, root)
return success({
"original": str(root.get_path_to(node)),
"duplicate": str(root.get_path_to(dup)),
"name": str(dup.name),
})
func _move_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "new_parent_path")
if result2[1] != null:
return result2[1]
var new_parent_path: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if node == root:
return error_invalid_params("Cannot move the root node")
var new_parent := find_node_by_path(new_parent_path)
if new_parent == null:
return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes")
# Check we're not moving a node into its own subtree
if new_parent == node or node.is_ancestor_of(new_parent):
return error_invalid_params("Cannot move a node into its own subtree")
var old_parent := node.get_parent()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Move %s" % node.name)
undo_redo.add_do_method(old_parent, "remove_child", node)
undo_redo.add_do_method(new_parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_undo_method(new_parent, "remove_child", node)
undo_redo.add_undo_method(old_parent, "add_child", node)
undo_redo.add_undo_method(node, "set_owner", root)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(node, root)
return success({
"node": str(node.name),
"old_parent": str(root.get_path_to(old_parent)),
"new_parent": str(root.get_path_to(new_parent)),
"new_path": str(root.get_path_to(node)),
})
func _update_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value: Variant = params["value"]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Check property exists
if not property in node:
var available: Array = []
for prop in node.get_property_list():
if prop["usage"] & PROPERTY_USAGE_EDITOR:
available.append(prop["name"])
return error_not_found("Property '%s' on %s" % [property, node.get_class()],
"Available: %s" % str(available.slice(0, 20)))
var old_value: Variant = node.get(property)
var target_type := typeof(old_value)
var parsed_value: Variant = PropertyParser.parse_value(value, target_type)
# Handle @export node references (e.g. @export var hud: HUD)
# typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set,
# neither resolves a string path to a node — check the property hint instead
if value is String:
for prop in node.get_property_list():
if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE:
var target_node: Node = node.get_node_or_null(NodePath(value))
if target_node == null:
target_node = root.get_node_or_null(NodePath(value))
if target_node == null:
return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property)
parsed_value = target_node
break
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
undo_redo.add_do_property(node, property, parsed_value)
undo_redo.add_undo_property(node, property, old_value)
undo_redo.commit_action()
return success({
"node": str(root.get_path_to(node)),
"property": property,
"old_value": PropertyParser.serialize_value(old_value),
"new_value": PropertyParser.serialize_value(node.get(property)),
})
func _get_node_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var category: String = optional_string(params, "category", "")
var props := NodeUtils.get_node_properties_dict(node)
# Filter by category if specified
if not category.is_empty():
var filtered: Dictionary = {}
for key: String in props:
if key.begins_with(category):
filtered[key] = props[key]
props = filtered
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"properties": props,
})
func _add_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "property")
if result2[1] != null:
return result2[1]
var property: String = result2[0]
var result3 := require_string(params, "resource_type")
if result3[1] != null:
return result3[1]
var resource_type: String = result3[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not ClassDB.class_exists(resource_type):
return error_invalid_params("Unknown resource type: %s" % resource_type)
if not ClassDB.is_parent_class(resource_type, "Resource"):
return error_invalid_params("'%s' is not a Resource type" % resource_type)
var resource: Resource = ClassDB.instantiate(resource_type)
if resource == null:
return error_internal("Failed to create resource: %s" % resource_type)
# Apply resource properties if provided
var resource_props: Dictionary = params.get("resource_properties", {})
for prop_name: String in resource_props:
if prop_name in resource:
var current: Variant = resource.get(prop_name)
resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current)))
var old_value: Variant = node.get(property) if property in node else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name])
undo_redo.add_do_property(node, property, resource)
undo_redo.add_undo_property(node, property, old_value)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"property": property,
"resource_type": resource_type,
})
func _set_anchor_preset(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "preset")
if result2[1] != null:
return result2[1]
var preset_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not node is Control:
return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()])
var control: Control = node
var presets := {
"top_left": Control.PRESET_TOP_LEFT,
"top_right": Control.PRESET_TOP_RIGHT,
"bottom_left": Control.PRESET_BOTTOM_LEFT,
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
"center_left": Control.PRESET_CENTER_LEFT,
"center_top": Control.PRESET_CENTER_TOP,
"center_right": Control.PRESET_CENTER_RIGHT,
"center_bottom": Control.PRESET_CENTER_BOTTOM,
"center": Control.PRESET_CENTER,
"left_wide": Control.PRESET_LEFT_WIDE,
"top_wide": Control.PRESET_TOP_WIDE,
"right_wide": Control.PRESET_RIGHT_WIDE,
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
"full_rect": Control.PRESET_FULL_RECT,
}
if not presets.has(preset_name):
return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()])
var keep_offsets: bool = optional_bool(params, "keep_offsets", false)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set anchor preset on %s" % node.name)
# Store old values
var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom]
var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom]
var target: Control = control.duplicate() as Control
target.set_anchors_and_offsets_preset(presets[preset_name],
Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE)
undo_redo.add_do_property(control, "anchor_left", target.anchor_left)
undo_redo.add_do_property(control, "anchor_top", target.anchor_top)
undo_redo.add_do_property(control, "anchor_right", target.anchor_right)
undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom)
undo_redo.add_do_property(control, "offset_left", target.offset_left)
undo_redo.add_do_property(control, "offset_top", target.offset_top)
undo_redo.add_do_property(control, "offset_right", target.offset_right)
undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom)
undo_redo.add_undo_property(control, "anchor_left", old_anchors[0])
undo_redo.add_undo_property(control, "anchor_top", old_anchors[1])
undo_redo.add_undo_property(control, "anchor_right", old_anchors[2])
undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3])
undo_redo.add_undo_property(control, "offset_left", old_offsets[0])
undo_redo.add_undo_property(control, "offset_top", old_offsets[1])
undo_redo.add_undo_property(control, "offset_right", old_offsets[2])
undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3])
target.free()
undo_redo.commit_action()
return success({"node_path": str(root.get_path_to(control)), "preset": preset_name})
func _rename_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "new_name")
if result2[1] != null:
return result2[1]
var new_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var old_name: String = node.name
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
undo_redo.add_do_property(node, "name", new_name)
undo_redo.add_undo_property(node, "name", old_name)
undo_redo.commit_action()
return success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))})
func _connect_signal(params: Dictionary) -> Dictionary:
var result := require_string(params, "source_path")
if result[1] != null:
return result[1]
var source_path: String = result[0]
var result2 := require_string(params, "signal_name")
if result2[1] != null:
return result2[1]
var signal_name: String = result2[0]
var result3 := require_string(params, "target_path")
if result3[1] != null:
return result3[1]
var target_path: String = result3[0]
var result4 := require_string(params, "method_name")
if result4[1] != null:
return result4[1]
var method_name: String = result4[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var source := find_node_by_path(source_path)
if source == null:
return error_not_found("Source node '%s'" % source_path)
var target := find_node_by_path(target_path)
if target == null:
return error_not_found("Target node '%s'" % target_path)
if not source.has_signal(signal_name):
return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()])
if source.is_connected(signal_name, Callable(target, method_name)):
return success({"already_connected": true, "signal": signal_name})
var callable := Callable(target, method_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Connect signal")
undo_redo.add_do_method(source, "connect", signal_name, callable)
undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
undo_redo.commit_action()
return success({
"source": str(root.get_path_to(source)),
"signal": signal_name,
"target": str(root.get_path_to(target)),
"method": method_name,
"connected": true,
})
func _disconnect_signal(params: Dictionary) -> Dictionary:
var result := require_string(params, "source_path")
if result[1] != null:
return result[1]
var source_path: String = result[0]
var result2 := require_string(params, "signal_name")
if result2[1] != null:
return result2[1]
var signal_name: String = result2[0]
var result3 := require_string(params, "target_path")
if result3[1] != null:
return result3[1]
var target_path: String = result3[0]
var result4 := require_string(params, "method_name")
if result4[1] != null:
return result4[1]
var method_name: String = result4[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var source := find_node_by_path(source_path)
if source == null:
return error_not_found("Source node '%s'" % source_path)
var target := find_node_by_path(target_path)
if target == null:
return error_not_found("Target node '%s'" % target_path)
if not source.is_connected(signal_name, Callable(target, method_name)):
return success({"was_connected": false})
var callable := Callable(target, method_name)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Disconnect signal")
undo_redo.add_do_method(source, "disconnect", signal_name, callable)
undo_redo.add_undo_method(source, "connect", signal_name, callable)
undo_redo.commit_action()
return success({
"source": str(root.get_path_to(source)),
"signal": signal_name,
"target": str(root.get_path_to(target)),
"method": method_name,
"disconnected": true,
})
func _get_node_groups(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var groups: Array = []
for group: StringName in node.get_groups():
var g := str(group)
# Filter out internal groups (start with _)
if not g.begins_with("_"):
groups.append(g)
return success({
"node_path": str(root.get_path_to(node)),
"groups": groups,
"count": groups.size(),
})
func _set_node_groups(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
if not params.has("groups") or not params["groups"] is Array:
return error_invalid_params("'groups' array is required")
var desired_groups: Array = params["groups"]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Get current non-internal groups
var current_groups: Array = []
for group: StringName in node.get_groups():
var g := str(group)
if not g.begins_with("_"):
current_groups.append(g)
var added: Array = []
var removed: Array = []
for group: String in current_groups:
if group not in desired_groups:
removed.append(group)
for group in desired_groups:
var g: String = str(group)
if g not in current_groups:
added.append(g)
if not added.is_empty() or not removed.is_empty():
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set node groups")
for group: String in removed:
undo_redo.add_do_method(node, "remove_from_group", group)
undo_redo.add_undo_method(node, "add_to_group", group, true)
for group: String in added:
undo_redo.add_do_method(node, "add_to_group", group, true)
undo_redo.add_undo_method(node, "remove_from_group", group)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"groups": desired_groups,
"added": added,
"removed": removed,
})
func _find_nodes_in_group(params: Dictionary) -> Dictionary:
var result := require_string(params, "group")
if result[1] != null:
return result[1]
var group_name: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var matches: Array = []
_find_in_group_recursive(root, root, group_name, matches)
return success({
"group": group_name,
"nodes": matches,
"count": matches.size(),
})
func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void:
if node.is_in_group(group_name):
matches.append({
"name": node.name,
"path": str(root.get_path_to(node)),
"type": node.get_class(),
})
for child in node.get_children():
_find_in_group_recursive(child, root, group_name, matches)

View File

@@ -0,0 +1,612 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_particles": _create_particles,
"set_particle_material": _set_particle_material,
"set_particle_color_gradient": _set_particle_color_gradient,
"apply_particle_preset": _apply_particle_preset,
"get_particle_info": _get_particle_info,
}
func _get_particles_node(node_path: String) -> GPUParticles2D:
# Returns any GPUParticles2D or GPUParticles3D (both share similar API)
var node := find_node_by_path(node_path)
if node is GPUParticles2D:
return node as GPUParticles2D
return null
func _get_particles_node_any(node_path: String) -> Node:
var node := find_node_by_path(node_path)
if node is GPUParticles2D or node is GPUParticles3D:
return node
return null
func _parse_color(color_str: String) -> Color:
# Support hex "#RRGGBB", "#RRGGBBAA", or named colors
if color_str.begins_with("#"):
return Color.html(color_str)
# Try named color
match color_str.to_lower():
"red": return Color.RED
"green": return Color.GREEN
"blue": return Color.BLUE
"white": return Color.WHITE
"black": return Color.BLACK
"yellow": return Color.YELLOW
"orange": return Color(1.0, 0.5, 0.0)
"gray", "grey": return Color.GRAY
"cyan": return Color.CYAN
"magenta": return Color.MAGENTA
"transparent": return Color(0, 0, 0, 0)
# Try Expression parser for Color(r,g,b,a)
var expr := Expression.new()
if expr.parse(color_str) == OK:
var parsed = expr.execute()
if parsed is Color:
return parsed
return Color.WHITE
func _create_particles(params: Dictionary) -> Dictionary:
var result := require_string(params, "parent_path")
if result[1] != null:
return result[1]
var parent_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Node at '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "Particles")
var is_3d: bool = optional_bool(params, "is_3d", false)
var amount: int = optional_int(params, "amount", 16)
var lifetime: float = float(params.get("lifetime", 1.0))
var one_shot: bool = optional_bool(params, "one_shot", false)
var explosiveness: float = float(params.get("explosiveness", 0.0))
var randomness: float = float(params.get("randomness", 0.0))
var emitting: bool = optional_bool(params, "emitting", true)
var particles_node: Node
if is_3d:
var p := GPUParticles3D.new()
p.name = node_name
p.amount = amount
p.lifetime = lifetime
p.one_shot = one_shot
p.explosiveness = explosiveness
p.randomness = randomness
p.emitting = emitting
var mat := ParticleProcessMaterial.new()
p.process_material = mat
particles_node = p
else:
var p := GPUParticles2D.new()
p.name = node_name
p.amount = amount
p.lifetime = lifetime
p.one_shot = one_shot
p.explosiveness = explosiveness
p.randomness = randomness
p.emitting = emitting
var mat := ParticleProcessMaterial.new()
p.process_material = mat
particles_node = p
add_child_with_undo(parent, particles_node, root, "MCP: Create particles")
return success({
"name": particles_node.name,
"parent": parent_path,
"is_3d": is_3d,
"amount": amount,
"lifetime": lifetime,
"one_shot": one_shot,
"created": true,
})
func _set_particle_material(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_mat: ParticleProcessMaterial = node.get("process_material")
var mat: ParticleProcessMaterial
if old_mat != null:
mat = old_mat.duplicate(true) as ParticleProcessMaterial
else:
mat = ParticleProcessMaterial.new()
var changes: Array = []
# Direction
if params.has("direction"):
var dir = params["direction"]
if dir is Dictionary:
mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0)))
changes.append("direction")
elif dir is String:
var expr := Expression.new()
if expr.parse(dir) == OK:
var parsed = expr.execute()
if parsed is Vector3:
mat.direction = parsed
changes.append("direction")
# Spread
if params.has("spread"):
mat.spread = float(params["spread"])
changes.append("spread")
# Initial velocity
if params.has("initial_velocity_min"):
mat.initial_velocity_min = float(params["initial_velocity_min"])
changes.append("initial_velocity_min")
if params.has("initial_velocity_max"):
mat.initial_velocity_max = float(params["initial_velocity_max"])
changes.append("initial_velocity_max")
# Gravity
if params.has("gravity"):
var grav = params["gravity"]
if grav is Dictionary:
mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0)))
changes.append("gravity")
elif grav is String:
var expr := Expression.new()
if expr.parse(grav) == OK:
var parsed = expr.execute()
if parsed is Vector3:
mat.gravity = parsed
changes.append("gravity")
# Scale
if params.has("scale_min"):
mat.scale_min = float(params["scale_min"])
changes.append("scale_min")
if params.has("scale_max"):
mat.scale_max = float(params["scale_max"])
changes.append("scale_max")
# Color
if params.has("color"):
mat.color = _parse_color(str(params["color"]))
changes.append("color")
# Emission shape
if params.has("emission_shape"):
var shape_str: String = str(params["emission_shape"]).to_lower()
match shape_str:
"point":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
"sphere":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
if params.has("emission_sphere_radius"):
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
"sphere_surface":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE
if params.has("emission_sphere_radius"):
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
"box":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
if params.has("emission_box_extents"):
var ext = params["emission_box_extents"]
if ext is Dictionary:
mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1)))
"ring":
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING
if params.has("emission_ring_radius"):
mat.emission_ring_radius = float(params["emission_ring_radius"])
if params.has("emission_ring_inner_radius"):
mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"])
if params.has("emission_ring_height"):
mat.emission_ring_height = float(params["emission_ring_height"])
changes.append("emission_shape")
# Angular velocity
if params.has("angular_velocity_min"):
mat.angular_velocity_min = float(params["angular_velocity_min"])
changes.append("angular_velocity_min")
if params.has("angular_velocity_max"):
mat.angular_velocity_max = float(params["angular_velocity_max"])
changes.append("angular_velocity_max")
# Orbit velocity
if params.has("orbit_velocity_min"):
mat.orbit_velocity_min = float(params["orbit_velocity_min"])
changes.append("orbit_velocity_min")
if params.has("orbit_velocity_max"):
mat.orbit_velocity_max = float(params["orbit_velocity_max"])
changes.append("orbit_velocity_max")
# Damping
if params.has("damping_min"):
mat.damping_min = float(params["damping_min"])
changes.append("damping_min")
if params.has("damping_max"):
mat.damping_max = float(params["damping_max"])
changes.append("damping_max")
# Attractor interaction
if params.has("attractor_interaction_enabled"):
mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"])
changes.append("attractor_interaction_enabled")
if not changes.is_empty():
set_property_with_undo(node, "process_material", mat, "MCP: Set particle material")
return success({"node_path": node_path, "changes": changes})
func _set_particle_color_gradient(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_mat: ParticleProcessMaterial = node.get("process_material")
var mat: ParticleProcessMaterial
if old_mat != null:
mat = old_mat.duplicate(true) as ParticleProcessMaterial
else:
mat = ParticleProcessMaterial.new()
if not params.has("stops") or not params["stops"] is Array:
return error_invalid_params("Missing required parameter: stops (array of {offset, color})")
var stops: Array = params["stops"]
if stops.is_empty():
return error_invalid_params("stops array must not be empty")
var gradient := Gradient.new()
# Remove default points
while gradient.get_point_count() > 0:
gradient.remove_point(0)
for stop in stops:
if stop is Dictionary:
var offset: float = float(stop.get("offset", 0.0))
var color: Color = _parse_color(str(stop.get("color", "#ffffff")))
gradient.add_point(offset, color)
var grad_tex := GradientTexture1D.new()
grad_tex.gradient = gradient
mat.color_ramp = grad_tex
set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient")
return success({"node_path": node_path, "stops_count": gradient.get_point_count()})
func _apply_particle_preset(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "preset")
if result2[1] != null:
return result2[1]
var preset: String = result2[0].to_lower()
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var old_state := _capture_particle_state(node)
var preset_state := {}
var mat := ParticleProcessMaterial.new()
var is_2d: bool = node is GPUParticles2D
# Default gravity for 2D (Y-down) vs 3D (Y-down)
var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0)
var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0)
var gravity_none := Vector3.ZERO
match preset:
"explosion":
preset_state["amount"] = 32
preset_state["lifetime"] = 0.6
preset_state["one_shot"] = true
preset_state["explosiveness"] = 1.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 100.0 if is_2d else 5.0
mat.initial_velocity_max = 200.0 if is_2d else 10.0
mat.gravity = gravity_down * 0.5
mat.damping_min = 2.0
mat.damping_max = 4.0
mat.scale_min = 0.5
mat.scale_max = 1.5
mat.color = Color(1.0, 0.6, 0.1)
_apply_gradient(mat, [
{"offset": 0.0, "color": Color.WHITE},
{"offset": 0.3, "color": Color(1.0, 0.8, 0.2)},
{"offset": 0.7, "color": Color(1.0, 0.3, 0.0)},
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
])
"fire":
preset_state["amount"] = 24
preset_state["lifetime"] = 1.2
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 15.0
mat.initial_velocity_min = 30.0 if is_2d else 1.5
mat.initial_velocity_max = 60.0 if is_2d else 3.0
mat.gravity = gravity_none
mat.scale_min = 0.8
mat.scale_max = 1.5
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(1.0, 1.0, 0.5)},
{"offset": 0.3, "color": Color(1.0, 0.6, 0.0)},
{"offset": 0.7, "color": Color(0.8, 0.2, 0.0)},
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
])
"smoke":
preset_state["amount"] = 16
preset_state["lifetime"] = 3.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 25.0
mat.initial_velocity_min = 10.0 if is_2d else 0.5
mat.initial_velocity_max = 25.0 if is_2d else 1.2
mat.gravity = gravity_none
mat.scale_min = 1.5
mat.scale_max = 3.0
mat.damping_min = 1.0
mat.damping_max = 2.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)},
{"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)},
{"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)},
])
"sparks":
preset_state["amount"] = 48
preset_state["lifetime"] = 0.4
preset_state["one_shot"] = true
preset_state["explosiveness"] = 0.95
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 200.0 if is_2d else 8.0
mat.initial_velocity_max = 400.0 if is_2d else 16.0
mat.gravity = gravity_down
mat.scale_min = 0.1
mat.scale_max = 0.3
mat.damping_min = 1.0
mat.damping_max = 3.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(1.0, 1.0, 0.8)},
{"offset": 0.5, "color": Color(1.0, 0.7, 0.2)},
{"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)},
])
"rain":
preset_state["amount"] = 64
preset_state["lifetime"] = 0.8
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
mat.spread = 5.0
mat.initial_velocity_min = 300.0 if is_2d else 12.0
mat.initial_velocity_max = 400.0 if is_2d else 16.0
mat.gravity = gravity_down
mat.scale_min = 0.1
mat.scale_max = 0.2
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
mat.color = Color(0.6, 0.7, 1.0, 0.7)
"snow":
preset_state["amount"] = 48
preset_state["lifetime"] = 4.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
mat.spread = 20.0
mat.initial_velocity_min = 20.0 if is_2d else 0.8
mat.initial_velocity_max = 40.0 if is_2d else 1.5
mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0)
mat.scale_min = 0.3
mat.scale_max = 0.8
mat.angular_velocity_min = -45.0
mat.angular_velocity_max = 45.0
mat.damping_min = 0.5
mat.damping_max = 1.5
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
mat.color = Color(1.0, 1.0, 1.0, 0.9)
"magic":
preset_state["amount"] = 24
preset_state["lifetime"] = 2.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 20.0 if is_2d else 1.0
mat.initial_velocity_max = 50.0 if is_2d else 2.5
mat.gravity = gravity_none
mat.orbit_velocity_min = 0.5
mat.orbit_velocity_max = 1.5
mat.scale_min = 0.3
mat.scale_max = 0.8
mat.damping_min = 1.0
mat.damping_max = 2.0
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.3, 0.5, 1.0)},
{"offset": 0.25, "color": Color(1.0, 0.3, 0.8)},
{"offset": 0.5, "color": Color(0.3, 1.0, 0.5)},
{"offset": 0.75, "color": Color(1.0, 0.8, 0.2)},
{"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)},
])
"dust":
preset_state["amount"] = 12
preset_state["lifetime"] = 5.0
preset_state["one_shot"] = false
preset_state["explosiveness"] = 0.0
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
mat.spread = 180.0
mat.initial_velocity_min = 3.0 if is_2d else 0.1
mat.initial_velocity_max = 8.0 if is_2d else 0.3
mat.gravity = gravity_none
mat.scale_min = 0.2
mat.scale_max = 0.5
mat.damping_min = 0.5
mat.damping_max = 1.0
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3)
_apply_gradient(mat, [
{"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
{"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)},
{"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)},
{"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
])
_:
return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset)
preset_state["process_material"] = mat
_register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset")
return success({"node_path": node_path, "preset": preset, "applied": true})
func _capture_particle_state(node: Node) -> Dictionary:
var state := {}
for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]:
if property in node:
state[property] = node.get(property)
return state
func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
for property: String in new_state:
undo_redo.add_do_property(node, property, new_state[property])
if new_state[property] is Resource:
undo_redo.add_do_reference(new_state[property])
undo_redo.add_undo_property(node, property, old_state.get(property, null))
if old_state.get(property, null) is Resource:
undo_redo.add_undo_reference(old_state[property])
undo_redo.commit_action()
func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void:
var gradient := Gradient.new()
# Remove default points before adding custom stops
for i in range(gradient.get_point_count() - 1, -1, -1):
gradient.remove_point(i)
for stop in stops:
gradient.add_point(stop["offset"], stop["color"])
var grad_tex := GradientTexture1D.new()
grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode
grad_tex.gradient = gradient
# Defer color_ramp assignment to avoid editor crash during rendering
mat.set_deferred("color_ramp", grad_tex)
func _get_particle_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := _get_particles_node_any(node_path)
if node == null:
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
var info: Dictionary = {
"node_path": node_path,
"type": node.get_class(),
"amount": node.get("amount"),
"lifetime": node.get("lifetime"),
"one_shot": node.get("one_shot"),
"explosiveness": node.get("explosiveness"),
"randomness": node.get("randomness"),
"emitting": node.get("emitting"),
}
var mat: ParticleProcessMaterial = node.get("process_material")
if mat != null and mat is ParticleProcessMaterial:
var mat_info: Dictionary = {
"direction": str(mat.direction),
"spread": mat.spread,
"initial_velocity_min": mat.initial_velocity_min,
"initial_velocity_max": mat.initial_velocity_max,
"gravity": str(mat.gravity),
"scale_min": mat.scale_min,
"scale_max": mat.scale_max,
"color": str(mat.color),
"angular_velocity_min": mat.angular_velocity_min,
"angular_velocity_max": mat.angular_velocity_max,
"orbit_velocity_min": mat.orbit_velocity_min,
"orbit_velocity_max": mat.orbit_velocity_max,
"damping_min": mat.damping_min,
"damping_max": mat.damping_max,
"attractor_interaction_enabled": mat.attractor_interaction_enabled,
}
# Emission shape
var shape_name: String
match mat.emission_shape:
ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point"
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere"
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface"
ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box"
ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring"
_: shape_name = "unknown(%d)" % mat.emission_shape
mat_info["emission_shape"] = shape_name
match mat.emission_shape:
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE:
mat_info["emission_sphere_radius"] = mat.emission_sphere_radius
ParticleProcessMaterial.EMISSION_SHAPE_BOX:
mat_info["emission_box_extents"] = str(mat.emission_box_extents)
ParticleProcessMaterial.EMISSION_SHAPE_RING:
mat_info["emission_ring_radius"] = mat.emission_ring_radius
mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius
mat_info["emission_ring_height"] = mat.emission_ring_height
# Color gradient
if mat.color_ramp != null and mat.color_ramp is GradientTexture1D:
var grad_tex: GradientTexture1D = mat.color_ramp
if grad_tex.gradient != null:
var gradient_stops: Array = []
var grad: Gradient = grad_tex.gradient
for i in grad.get_point_count():
gradient_stops.append({
"offset": grad.get_offset(i),
"color": str(grad.get_color(i)),
})
mat_info["color_ramp"] = gradient_stops
info["material"] = mat_info
else:
info["material"] = null
return success(info)

View File

@@ -0,0 +1,757 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"setup_collision": _setup_collision,
"set_physics_layers": _set_physics_layers,
"get_physics_layers": _get_physics_layers,
"add_raycast": _add_raycast,
"setup_physics_body": _setup_physics_body,
"get_collision_info": _get_collision_info,
}
## Determine if a node (or its ancestors) lives in a 2D or 3D context.
## Returns "2d", "3d", or "" if undetermined.
func _detect_dimension(node: Node) -> String:
if node is Node2D or node is Control:
return "2d"
if node is Node3D:
return "3d"
# Walk up the tree
var parent := node.get_parent()
while parent != null:
if parent is Node2D or parent is Control:
return "2d"
if parent is Node3D:
return "3d"
parent = parent.get_parent()
return ""
func _setup_collision(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "shape")
if result2[1] != null:
return result2[1]
var shape_name: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var dim := _detect_dimension(node)
if dim.is_empty():
# Allow explicit override
dim = optional_string(params, "dimension", "2d")
# Validate parent can have collision children
var valid_parents_2d := ["PhysicsBody2D", "Area2D", "StaticBody2D", "CharacterBody2D", "RigidBody2D", "AnimatableBody2D"]
var valid_parents_3d := ["PhysicsBody3D", "Area3D", "StaticBody3D", "CharacterBody3D", "RigidBody3D", "AnimatableBody3D"]
var is_valid_parent := false
if dim == "2d":
for vp: String in valid_parents_2d:
if node.is_class(vp):
is_valid_parent = true
break
else:
for vp: String in valid_parents_3d:
if node.is_class(vp):
is_valid_parent = true
break
if not is_valid_parent:
return error_invalid_params("Node '%s' (%s) is not a physics body or area. CollisionShape should be added to a PhysicsBody or Area node." % [node_path, node.get_class()])
# Create shape resource
var shape: Resource = null
var child_name := "CollisionShape"
if dim == "2d":
match shape_name:
"rectangle", "rect":
shape = RectangleShape2D.new()
var w: float = float(params.get("width", 32.0))
var h: float = float(params.get("height", 32.0))
shape.size = Vector2(w, h)
"circle":
shape = CircleShape2D.new()
shape.radius = float(params.get("radius", 16.0))
"capsule":
shape = CapsuleShape2D.new()
shape.radius = float(params.get("radius", 16.0))
shape.height = float(params.get("height", 40.0))
"segment":
shape = SegmentShape2D.new()
shape.a = Vector2(float(params.get("ax", 0.0)), float(params.get("ay", 0.0)))
shape.b = Vector2(float(params.get("bx", 32.0)), float(params.get("by", 0.0)))
"custom":
# ConvexPolygonShape2D — expects "points" as array of [x,y] pairs
shape = ConvexPolygonShape2D.new()
var points_data: Array = params.get("points", [])
var pool: PackedVector2Array = PackedVector2Array()
for p: Variant in points_data:
if p is Array and p.size() >= 2:
pool.append(Vector2(float(p[0]), float(p[1])))
if pool.size() >= 3:
shape.points = pool
_:
return error_invalid_params("Unknown 2D shape: '%s'. Available: rectangle, circle, capsule, segment, custom" % shape_name)
var collision_node := CollisionShape2D.new()
collision_node.shape = shape
collision_node.name = child_name
var disabled: bool = optional_bool(params, "disabled", false)
collision_node.disabled = disabled
var one_way: bool = optional_bool(params, "one_way_collision", false)
collision_node.one_way_collision = one_way
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add CollisionShape2D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", collision_node)
undo_redo.add_do_method(collision_node, "set_owner", root)
undo_redo.add_do_reference(collision_node)
undo_redo.add_undo_method(node, "remove_child", collision_node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(collision_node)),
"shape_type": shape.get_class(),
"dimension": "2D",
})
else:
# 3D shapes
match shape_name:
"box", "rectangle", "rect":
shape = BoxShape3D.new()
var sx: float = float(params.get("width", 1.0))
var sy: float = float(params.get("height", 1.0))
var sz: float = float(params.get("depth", 1.0))
shape.size = Vector3(sx, sy, sz)
"sphere", "circle":
shape = SphereShape3D.new()
shape.radius = float(params.get("radius", 0.5))
"capsule":
shape = CapsuleShape3D.new()
shape.radius = float(params.get("radius", 0.5))
shape.height = float(params.get("height", 2.0))
"cylinder":
shape = CylinderShape3D.new()
shape.radius = float(params.get("radius", 0.5))
shape.height = float(params.get("height", 2.0))
"convex", "custom":
shape = ConvexPolygonShape3D.new()
var points_data: Array = params.get("points", [])
var pool: PackedVector3Array = PackedVector3Array()
for p: Variant in points_data:
if p is Array and p.size() >= 3:
pool.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
if pool.size() >= 4:
shape.points = pool
_:
return error_invalid_params("Unknown 3D shape: '%s'. Available: box, sphere, capsule, cylinder, convex" % shape_name)
var collision_node := CollisionShape3D.new()
collision_node.shape = shape
collision_node.name = child_name
var disabled: bool = optional_bool(params, "disabled", false)
collision_node.disabled = disabled
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add CollisionShape3D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", collision_node)
undo_redo.add_do_method(collision_node, "set_owner", root)
undo_redo.add_do_reference(collision_node)
undo_redo.add_undo_method(node, "remove_child", collision_node)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(collision_node)),
"shape_type": shape.get_class(),
"dimension": "3D",
})
func _get_layer_name(dim: String, layer_index: int) -> String:
var setting_key := "layer_names/%s_physics/layer_%d" % [dim, layer_index]
if ProjectSettings.has_setting(setting_key):
var name_val: Variant = ProjectSettings.get_setting(setting_key)
if name_val is String and not (name_val as String).is_empty():
return name_val as String
return ""
func _layer_bitmask_to_info(bitmask: int, dim: String) -> Array:
var layers: Array = []
for i in range(1, 33):
if bitmask & (1 << (i - 1)):
var layer_name := _get_layer_name(dim, i)
var entry: Dictionary = {"layer": i}
if not layer_name.is_empty():
entry["name"] = layer_name
layers.append(entry)
return layers
func _set_physics_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
# Check node has collision_layer/collision_mask properties
if not "collision_layer" in node:
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set physics layers on %s" % node.name)
var changes: Dictionary = {}
if params.has("collision_layer"):
var old_layer: int = node.get("collision_layer")
var new_layer: int = _parse_layer_value(params["collision_layer"])
undo_redo.add_do_property(node, "collision_layer", new_layer)
undo_redo.add_undo_property(node, "collision_layer", old_layer)
changes["collision_layer"] = new_layer
if params.has("collision_mask"):
var old_mask: int = node.get("collision_mask")
var new_mask: int = _parse_layer_value(params["collision_mask"])
undo_redo.add_do_property(node, "collision_mask", new_mask)
undo_redo.add_undo_property(node, "collision_mask", old_mask)
changes["collision_mask"] = new_mask
if changes.is_empty():
return error_invalid_params("Must provide collision_layer and/or collision_mask")
undo_redo.commit_action()
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
var result_data: Dictionary = {
"node_path": str(root.get_path_to(node)),
}
if changes.has("collision_layer"):
result_data["collision_layer"] = changes["collision_layer"]
result_data["collision_layer_info"] = _layer_bitmask_to_info(changes["collision_layer"], dim)
if changes.has("collision_mask"):
result_data["collision_mask"] = changes["collision_mask"]
result_data["collision_mask_info"] = _layer_bitmask_to_info(changes["collision_mask"], dim)
return success(result_data)
## Parse layer value: can be an int bitmask, or an array of layer numbers [1, 3, 5]
func _parse_layer_value(value: Variant) -> int:
if value is int or value is float:
return int(value)
if value is Array:
var bitmask: int = 0
for layer_num: Variant in value:
var n: int = int(layer_num)
if n >= 1 and n <= 32:
bitmask |= (1 << (n - 1))
return bitmask
# Try parsing as int
return int(value)
func _get_physics_layers(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not "collision_layer" in node:
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
var layer: int = node.get("collision_layer")
var mask: int = node.get("collision_mask")
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"collision_layer": layer,
"collision_layer_info": _layer_bitmask_to_info(layer, dim),
"collision_mask": mask,
"collision_mask_info": _layer_bitmask_to_info(mask, dim),
})
func _add_raycast(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var dim := _detect_dimension(node)
if dim.is_empty():
dim = optional_string(params, "dimension", "2d")
var ray_name: String = optional_string(params, "name", "RayCast")
var enabled: bool = optional_bool(params, "enabled", true)
var collision_mask: int = optional_int(params, "collision_mask", 1)
var collide_with_areas: bool = optional_bool(params, "collide_with_areas", false)
var collide_with_bodies: bool = optional_bool(params, "collide_with_bodies", true)
var hit_from_inside: bool = optional_bool(params, "hit_from_inside", false)
var undo_redo := get_undo_redo()
if dim == "2d":
var ray := RayCast2D.new()
ray.name = ray_name
ray.enabled = enabled
ray.collision_mask = collision_mask
ray.collide_with_areas = collide_with_areas
ray.collide_with_bodies = collide_with_bodies
ray.hit_from_inside = hit_from_inside
var tx: float = float(params.get("target_x", 0.0))
var ty: float = float(params.get("target_y", 50.0))
ray.target_position = Vector2(tx, ty)
undo_redo.create_action("MCP: Add RayCast2D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", ray)
undo_redo.add_do_method(ray, "set_owner", root)
undo_redo.add_do_reference(ray)
undo_redo.add_undo_method(node, "remove_child", ray)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(ray)),
"type": "RayCast2D",
"target_position": "Vector2(%s, %s)" % [tx, ty],
"collision_mask": collision_mask,
})
else:
var ray := RayCast3D.new()
ray.name = ray_name
ray.enabled = enabled
ray.collision_mask = collision_mask
ray.collide_with_areas = collide_with_areas
ray.collide_with_bodies = collide_with_bodies
ray.hit_from_inside = hit_from_inside
var tx: float = float(params.get("target_x", 0.0))
var ty: float = float(params.get("target_y", -1.0))
var tz: float = float(params.get("target_z", 0.0))
ray.target_position = Vector3(tx, ty, tz)
undo_redo.create_action("MCP: Add RayCast3D to %s" % node.name)
undo_redo.add_do_method(node, "add_child", ray)
undo_redo.add_do_method(ray, "set_owner", root)
undo_redo.add_do_reference(ray)
undo_redo.add_undo_method(node, "remove_child", ray)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(ray)),
"type": "RayCast3D",
"target_position": "Vector3(%s, %s, %s)" % [tx, ty, tz],
"collision_mask": collision_mask,
})
func _setup_physics_body(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Setup physics body %s" % node.name)
var applied: Dictionary = {}
if node is CharacterBody2D or node is CharacterBody3D:
# CharacterBody properties
if params.has("floor_stop_on_slope"):
var old_val: bool = node.floor_stop_on_slope
var new_val: bool = bool(params["floor_stop_on_slope"])
undo_redo.add_do_property(node, "floor_stop_on_slope", new_val)
undo_redo.add_undo_property(node, "floor_stop_on_slope", old_val)
applied["floor_stop_on_slope"] = new_val
if params.has("floor_max_angle"):
var old_val: float = node.floor_max_angle
var new_val: float = float(params["floor_max_angle"])
undo_redo.add_do_property(node, "floor_max_angle", new_val)
undo_redo.add_undo_property(node, "floor_max_angle", old_val)
applied["floor_max_angle"] = new_val
if params.has("floor_snap_length"):
var old_val: float = node.floor_snap_length
var new_val: float = float(params["floor_snap_length"])
undo_redo.add_do_property(node, "floor_snap_length", new_val)
undo_redo.add_undo_property(node, "floor_snap_length", old_val)
applied["floor_snap_length"] = new_val
if params.has("wall_min_slide_angle"):
var old_val: float = node.wall_min_slide_angle
var new_val: float = float(params["wall_min_slide_angle"])
undo_redo.add_do_property(node, "wall_min_slide_angle", new_val)
undo_redo.add_undo_property(node, "wall_min_slide_angle", old_val)
applied["wall_min_slide_angle"] = new_val
if params.has("motion_mode"):
var mode_str: String = str(params["motion_mode"])
var mode_val: int = 0
if node is CharacterBody2D:
match mode_str.to_lower():
"grounded":
mode_val = CharacterBody2D.MOTION_MODE_GROUNDED
"floating":
mode_val = CharacterBody2D.MOTION_MODE_FLOATING
_:
mode_val = int(params["motion_mode"])
else:
match mode_str.to_lower():
"grounded":
mode_val = CharacterBody3D.MOTION_MODE_GROUNDED
"floating":
mode_val = CharacterBody3D.MOTION_MODE_FLOATING
_:
mode_val = int(params["motion_mode"])
var old_val: int = node.motion_mode
undo_redo.add_do_property(node, "motion_mode", mode_val)
undo_redo.add_undo_property(node, "motion_mode", old_val)
applied["motion_mode"] = mode_str
if params.has("max_slides"):
var old_val: int = node.max_slides
var new_val: int = int(params["max_slides"])
undo_redo.add_do_property(node, "max_slides", new_val)
undo_redo.add_undo_property(node, "max_slides", old_val)
applied["max_slides"] = new_val
if params.has("slide_on_ceiling"):
var old_val: bool = node.slide_on_ceiling
var new_val: bool = bool(params["slide_on_ceiling"])
undo_redo.add_do_property(node, "slide_on_ceiling", new_val)
undo_redo.add_undo_property(node, "slide_on_ceiling", old_val)
applied["slide_on_ceiling"] = new_val
elif node is RigidBody2D or node is RigidBody3D:
# RigidBody properties
if params.has("mass"):
var old_val: float = node.mass
var new_val: float = float(params["mass"])
undo_redo.add_do_property(node, "mass", new_val)
undo_redo.add_undo_property(node, "mass", old_val)
applied["mass"] = new_val
if params.has("gravity_scale"):
var old_val: float = node.gravity_scale
var new_val: float = float(params["gravity_scale"])
undo_redo.add_do_property(node, "gravity_scale", new_val)
undo_redo.add_undo_property(node, "gravity_scale", old_val)
applied["gravity_scale"] = new_val
if params.has("linear_damp"):
var old_val: float = node.linear_damp
var new_val: float = float(params["linear_damp"])
undo_redo.add_do_property(node, "linear_damp", new_val)
undo_redo.add_undo_property(node, "linear_damp", old_val)
applied["linear_damp"] = new_val
if params.has("angular_damp"):
var old_val: float = node.angular_damp
var new_val: float = float(params["angular_damp"])
undo_redo.add_do_property(node, "angular_damp", new_val)
undo_redo.add_undo_property(node, "angular_damp", old_val)
applied["angular_damp"] = new_val
if params.has("freeze"):
var old_val: bool = node.freeze
var new_val: bool = bool(params["freeze"])
undo_redo.add_do_property(node, "freeze", new_val)
undo_redo.add_undo_property(node, "freeze", old_val)
applied["freeze"] = new_val
if params.has("freeze_mode"):
var mode_str: String = str(params["freeze_mode"])
var mode_val: int = 0
if node is RigidBody2D:
match mode_str.to_lower():
"static":
mode_val = RigidBody2D.FREEZE_MODE_STATIC
"kinematic":
mode_val = RigidBody2D.FREEZE_MODE_KINEMATIC
_:
mode_val = int(params["freeze_mode"])
else:
match mode_str.to_lower():
"static":
mode_val = RigidBody3D.FREEZE_MODE_STATIC
"kinematic":
mode_val = RigidBody3D.FREEZE_MODE_KINEMATIC
_:
mode_val = int(params["freeze_mode"])
var old_val: int = node.freeze_mode
undo_redo.add_do_property(node, "freeze_mode", mode_val)
undo_redo.add_undo_property(node, "freeze_mode", old_val)
applied["freeze_mode"] = mode_str
if params.has("continuous_cd"):
if node is RigidBody2D:
var ccd_str: String = str(params["continuous_cd"])
var ccd_val: int = 0
match ccd_str.to_lower():
"disabled":
ccd_val = RigidBody2D.CCD_MODE_DISABLED
"cast_ray":
ccd_val = RigidBody2D.CCD_MODE_CAST_RAY
"cast_shape":
ccd_val = RigidBody2D.CCD_MODE_CAST_SHAPE
_:
ccd_val = int(params["continuous_cd"])
var old_val: int = node.continuous_cd
undo_redo.add_do_property(node, "continuous_cd", ccd_val)
undo_redo.add_undo_property(node, "continuous_cd", old_val)
applied["continuous_cd"] = ccd_str
else:
var old_val: bool = node.continuous_cd
var new_val: bool = bool(params["continuous_cd"])
undo_redo.add_do_property(node, "continuous_cd", new_val)
undo_redo.add_undo_property(node, "continuous_cd", old_val)
applied["continuous_cd"] = new_val
if params.has("contact_monitor"):
var old_val: bool = node.contact_monitor
var new_val: bool = bool(params["contact_monitor"])
undo_redo.add_do_property(node, "contact_monitor", new_val)
undo_redo.add_undo_property(node, "contact_monitor", old_val)
applied["contact_monitor"] = new_val
if params.has("max_contacts_reported"):
var old_val: int = node.max_contacts_reported
var new_val: int = int(params["max_contacts_reported"])
undo_redo.add_do_property(node, "max_contacts_reported", new_val)
undo_redo.add_undo_property(node, "max_contacts_reported", old_val)
applied["max_contacts_reported"] = new_val
elif node is StaticBody2D or node is StaticBody3D or node is AnimatableBody2D or node is AnimatableBody3D:
# StaticBody / AnimatableBody shared properties
if params.has("physics_material_override"):
# We just note it — use add_resource for complex resource assignment
return error_invalid_params("Use add_resource to set physics_material_override")
else:
return error_invalid_params("Node '%s' (%s) is not a recognized physics body type. Supported: CharacterBody2D/3D, RigidBody2D/3D, StaticBody2D/3D, AnimatableBody2D/3D" % [node_path, node.get_class()])
if applied.is_empty():
undo_redo.commit_action()
return error_invalid_params("No valid properties provided for %s" % node.get_class())
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"applied": applied,
})
func _get_collision_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
var include_children: bool = optional_bool(params, "include_children", true)
var info: Dictionary = {
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
}
# Collect physics body properties
if "collision_layer" in node:
var dim := _detect_dimension(node)
if dim.is_empty():
dim = "2d"
info["collision_layer"] = node.get("collision_layer")
info["collision_layer_info"] = _layer_bitmask_to_info(int(node.get("collision_layer")), dim)
info["collision_mask"] = node.get("collision_mask")
info["collision_mask_info"] = _layer_bitmask_to_info(int(node.get("collision_mask")), dim)
# Collect body-specific properties
if node is CharacterBody2D or node is CharacterBody3D:
info["body_settings"] = {
"motion_mode": node.motion_mode,
"floor_stop_on_slope": node.floor_stop_on_slope,
"floor_max_angle": node.floor_max_angle,
"floor_snap_length": node.floor_snap_length,
"wall_min_slide_angle": node.wall_min_slide_angle,
"max_slides": node.max_slides,
"slide_on_ceiling": node.slide_on_ceiling,
}
elif node is RigidBody2D or node is RigidBody3D:
info["body_settings"] = {
"mass": node.mass,
"gravity_scale": node.gravity_scale,
"linear_damp": node.linear_damp,
"angular_damp": node.angular_damp,
"freeze": node.freeze,
"freeze_mode": node.freeze_mode,
"contact_monitor": node.contact_monitor,
"max_contacts_reported": node.max_contacts_reported,
}
# Collect collision shapes
var shapes: Array = []
var raycasts: Array = []
var nodes_to_check: Array = [node]
if include_children:
var queue: Array = [node]
while queue.size() > 0:
var current: Node = queue.pop_front()
for child_idx in current.get_child_count():
var child := current.get_child(child_idx)
nodes_to_check.append(child)
queue.append(child)
for check_node: Node in nodes_to_check:
if check_node is CollisionShape2D:
var shape_info: Dictionary = {
"node_path": str(root.get_path_to(check_node)),
"disabled": check_node.disabled,
"one_way_collision": check_node.one_way_collision,
}
if check_node.shape != null:
shape_info["shape_type"] = check_node.shape.get_class()
if check_node.shape is RectangleShape2D:
shape_info["size"] = "Vector2(%s, %s)" % [check_node.shape.size.x, check_node.shape.size.y]
elif check_node.shape is CircleShape2D:
shape_info["radius"] = check_node.shape.radius
elif check_node.shape is CapsuleShape2D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
shapes.append(shape_info)
elif check_node is CollisionShape3D:
var shape_info: Dictionary = {
"node_path": str(root.get_path_to(check_node)),
"disabled": check_node.disabled,
}
if check_node.shape != null:
shape_info["shape_type"] = check_node.shape.get_class()
if check_node.shape is BoxShape3D:
shape_info["size"] = "Vector3(%s, %s, %s)" % [check_node.shape.size.x, check_node.shape.size.y, check_node.shape.size.z]
elif check_node.shape is SphereShape3D:
shape_info["radius"] = check_node.shape.radius
elif check_node.shape is CapsuleShape3D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
elif check_node.shape is CylinderShape3D:
shape_info["radius"] = check_node.shape.radius
shape_info["height"] = check_node.shape.height
shapes.append(shape_info)
elif check_node is CollisionPolygon2D:
shapes.append({
"node_path": str(root.get_path_to(check_node)),
"shape_type": "CollisionPolygon2D",
"disabled": check_node.disabled,
"one_way_collision": check_node.one_way_collision,
"polygon_points": check_node.polygon.size(),
})
elif check_node is CollisionPolygon3D:
shapes.append({
"node_path": str(root.get_path_to(check_node)),
"shape_type": "CollisionPolygon3D",
"disabled": check_node.disabled,
"polygon_points": check_node.polygon.size(),
})
elif check_node is RayCast2D:
raycasts.append({
"node_path": str(root.get_path_to(check_node)),
"type": "RayCast2D",
"enabled": check_node.enabled,
"target_position": "Vector2(%s, %s)" % [check_node.target_position.x, check_node.target_position.y],
"collision_mask": check_node.collision_mask,
"collide_with_areas": check_node.collide_with_areas,
"collide_with_bodies": check_node.collide_with_bodies,
})
elif check_node is RayCast3D:
raycasts.append({
"node_path": str(root.get_path_to(check_node)),
"type": "RayCast3D",
"enabled": check_node.enabled,
"target_position": "Vector3(%s, %s, %s)" % [check_node.target_position.x, check_node.target_position.y, check_node.target_position.z],
"collision_mask": check_node.collision_mask,
"collide_with_areas": check_node.collide_with_areas,
"collide_with_bodies": check_node.collide_with_bodies,
})
info["collision_shapes"] = shapes
info["raycasts"] = raycasts
return success(info)

View File

@@ -0,0 +1,69 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_performance_monitors": _get_performance_monitors,
"get_editor_performance": _get_editor_performance,
}
func _get_performance_monitors(params: Dictionary) -> Dictionary:
# Return all available performance monitors
var monitors := {}
monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS)
monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0
monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC)
monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX)
monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT)
monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)
monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT)
monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)
monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)
monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)
monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)
monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)
monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)
monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)
monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)
monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)
monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)
monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)
monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)
# Filter by category if requested
var category: String = optional_string(params, "category", "")
if not category.is_empty():
var filtered := {}
for key: String in monitors:
if key.begins_with(category):
filtered[key] = monitors[key]
return success({"monitors": filtered, "category": category})
return success({"monitors": monitors})
func _get_editor_performance(params: Dictionary) -> Dictionary:
# Quick summary for common use
var summary := {
"fps": Performance.get_monitor(Performance.TIME_FPS),
"frame_time_msec": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"objects_in_frame": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
"node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
"orphan_nodes": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
"memory_static_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / (1024.0 * 1024.0),
"video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0),
}
return success(summary)

View File

@@ -0,0 +1,390 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_project_info": _get_project_info,
"get_filesystem_tree": _get_filesystem_tree,
"search_files": _search_files,
"search_in_files": _search_in_files,
"get_project_settings": _get_project_settings,
"set_project_setting": _set_project_setting,
"uid_to_project_path": _uid_to_project_path,
"project_path_to_uid": _project_path_to_uid,
"add_autoload": _add_autoload,
"remove_autoload": _remove_autoload,
}
func _get_project_info(params: Dictionary) -> Dictionary:
var info := {}
info["project_name"] = ProjectSettings.get_setting("application/config/name", "")
info["godot_version"] = Engine.get_version_info()
info["project_path"] = ProjectSettings.globalize_path("res://")
info["main_scene"] = ProjectSettings.get_setting("application/run/main_scene", "")
# Viewport settings
info["viewport_width"] = ProjectSettings.get_setting("display/window/size/viewport_width", 0)
info["viewport_height"] = ProjectSettings.get_setting("display/window/size/viewport_height", 0)
info["window_width"] = ProjectSettings.get_setting("display/window/size/window_width_override", 0)
info["window_height"] = ProjectSettings.get_setting("display/window/size/window_height_override", 0)
# Rendering
info["renderer"] = ProjectSettings.get_setting("rendering/renderer/rendering_method", "")
# Autoloads
var autoloads := {}
for prop in ProjectSettings.get_property_list():
var name: String = prop["name"]
if name.begins_with("autoload/"):
autoloads[name.substr(9)] = ProjectSettings.get_setting(name)
info["autoloads"] = autoloads
return success(info)
func _get_filesystem_tree(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var filter: String = optional_string(params, "filter", "") # e.g. "*.gd", "*.tscn"
var max_depth: int = optional_int(params, "max_depth", 10)
var tree := _scan_directory(path, filter, max_depth, 0)
return success({"tree": tree})
func _scan_directory(path: String, filter: String, max_depth: int, depth: int) -> Dictionary:
var result := {"name": path.get_file(), "path": path, "type": "directory"}
if depth >= max_depth:
return result
var dir := DirAccess.open(path)
if dir == null:
return result
var children: Array = []
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
children.append(_scan_directory(full_path, filter, max_depth, depth + 1))
else:
if filter.is_empty() or file_name.match(filter):
children.append({
"name": file_name,
"path": full_path,
"type": "file",
})
file_name = dir.get_next()
dir.list_dir_end()
if not children.is_empty():
result["children"] = children
return result
func _search_files(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var file_type: String = optional_string(params, "file_type", "") # e.g. "gd", "tscn"
var max_results: int = optional_int(params, "max_results", 50)
var matches: Array = []
_search_recursive(path, query, file_type, matches, max_results)
return success({"matches": matches, "count": matches.size()})
func _search_recursive(path: String, query: String, file_type: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
_search_recursive(full_path, query, file_type, matches, max_results)
else:
# Check file type filter
if not file_type.is_empty() and file_name.get_extension() != file_type:
file_name = dir.get_next()
continue
# Fuzzy match: check if query is contained in filename (case insensitive)
if file_name.to_lower().contains(query.to_lower()):
matches.append(full_path)
# Also check glob pattern
elif file_name.match(query):
matches.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
func _get_project_settings(params: Dictionary) -> Dictionary:
var section: String = optional_string(params, "section", "")
var key: String = optional_string(params, "key", "")
# If specific key requested
if not key.is_empty():
if ProjectSettings.has_setting(key):
var value = ProjectSettings.get_setting(key)
return success({"key": key, "value": str(value), "type": typeof(value)})
else:
return error_not_found("Setting '%s'" % key)
# If section requested, return all settings in that section
var settings := {}
for prop in ProjectSettings.get_property_list():
var name: String = prop["name"]
if section.is_empty() or name.begins_with(section):
settings[name] = str(ProjectSettings.get_setting(name))
return success({"settings": settings, "count": settings.size()})
func _set_project_setting(params: Dictionary) -> Dictionary:
var result := require_string(params, "key")
if result[1] != null:
return result[1]
var key: String = result[0]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
var value = params["value"]
# Type conversion for common patterns
if value is String:
var s: String = value
# Try to parse typed values from string
if s.begins_with("Vector2("):
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed is Vector2:
value = parsed
elif s == "true":
value = true
elif s == "false":
value = false
elif s.is_valid_int():
value = s.to_int()
elif s.is_valid_float():
value = s.to_float()
ProjectSettings.set_setting(key, value)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"key": key,
"value": str(ProjectSettings.get_setting(key)),
"saved": true,
})
func _uid_to_project_path(params: Dictionary) -> Dictionary:
var result := require_string(params, "uid")
if result[1] != null:
return result[1]
var uid_str: String = result[0]
# Use ResourceUID to convert
var uid := ResourceUID.text_to_id(uid_str)
if uid == ResourceUID.INVALID_ID:
return error_invalid_params("Invalid UID format: %s" % uid_str)
if not ResourceUID.has_id(uid):
return error_not_found("UID '%s'" % uid_str)
var path := ResourceUID.get_id_path(uid)
return success({"uid": uid_str, "path": path})
func _project_path_to_uid(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not ResourceLoader.exists(path):
return error_not_found("Resource at '%s'" % path)
var uid := ResourceLoader.get_resource_uid(path)
if uid == ResourceUID.INVALID_ID:
return error(-32001, "No UID assigned to '%s'" % path)
var uid_str := ResourceUID.id_to_text(uid)
return success({"path": path, "uid": uid_str})
const _TEXT_EXTENSIONS: PackedStringArray = ["gd", "tscn", "tres", "cfg", "godot", "gdshader", "md", "txt", "json"]
func _search_in_files(params: Dictionary) -> Dictionary:
var result := require_string(params, "query")
if result[1] != null:
return result[1]
var query: String = result[0]
var path: String = optional_string(params, "path", "res://")
var max_results: int = optional_int(params, "max_results", 50)
var use_regex: bool = optional_bool(params, "regex", false)
var file_type: String = optional_string(params, "file_type", "")
var regex: RegEx = null
if use_regex:
regex = RegEx.new()
var err := regex.compile(query)
if err != OK:
return error_invalid_params("Invalid regex pattern: %s" % error_string(err))
var matches: Array = []
_search_in_files_recursive(path, query, regex, file_type, matches, max_results)
return success({"matches": matches, "count": matches.size(), "query": query})
func _search_in_files_recursive(path: String, query: String, regex: RegEx, file_type: String, matches: Array, max_results: int) -> void:
if matches.size() >= max_results:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty() and matches.size() < max_results:
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
# Skip addons and .godot directories
if file_name != "addons" and file_name != ".godot":
_search_in_files_recursive(full_path, query, regex, file_type, matches, max_results)
else:
var ext := file_name.get_extension()
# Filter by file type if specified, otherwise use text extensions
if not file_type.is_empty():
if ext != file_type:
file_name = dir.get_next()
continue
elif ext not in _TEXT_EXTENSIONS:
file_name = dir.get_next()
continue
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
for i in range(lines.size()):
if matches.size() >= max_results:
break
var line: String = lines[i]
var matched := false
if regex != null:
matched = regex.search(line) != null
else:
matched = line.contains(query)
if matched:
matches.append({
"file": full_path,
"line": i + 1,
"text": line.strip_edges(),
})
file_name = dir.get_next()
dir.list_dir_end()
func _add_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var autoload_name: String = result[0]
var result2 := require_string(params, "path")
if result2[1] != null:
return result2[1]
var autoload_path: String = result2[0]
if not FileAccess.file_exists(autoload_path):
return error_not_found("File '%s'" % autoload_path)
# Check if already exists
var setting_key := "autoload/" + autoload_name
if ProjectSettings.has_setting(setting_key):
return error(-32000, "Autoload '%s' already exists" % autoload_name, {
"current_value": str(ProjectSettings.get_setting(setting_key)),
"suggestion": "Use remove_autoload first to replace it",
})
# Autoload format: "*res://path.gd" (the * prefix means it's a singleton)
ProjectSettings.set_setting(setting_key, "*" + autoload_path)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"name": autoload_name,
"path": autoload_path,
"added": true,
})
func _remove_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var autoload_name: String = result[0]
var setting_key := "autoload/" + autoload_name
if not ProjectSettings.has_setting(setting_key):
return error_not_found("Autoload '%s'" % autoload_name)
var old_value: String = str(ProjectSettings.get_setting(setting_key))
ProjectSettings.clear(setting_key)
var err := ProjectSettings.save()
if err != OK:
return error_internal("Failed to save project settings: %s" % error_string(err))
return success({
"name": autoload_name,
"old_path": old_value,
"removed": true,
})

View File

@@ -0,0 +1,201 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"read_resource": _read_resource,
"edit_resource": _edit_resource,
"create_resource": _create_resource,
"get_resource_preview": _get_resource_preview,
}
func _read_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
var props: Dictionary = {}
for prop_info in resource.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path":
continue
props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name))
return success({
"path": path,
"type": resource.get_class(),
"resource_name": resource.resource_name,
"properties": props,
})
func _edit_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not params.has("properties") or not params["properties"] is Dictionary:
return error_invalid_params("'properties' dictionary is required")
var new_props: Dictionary = params["properties"]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
var changed: Dictionary = {}
for prop_name: String in new_props:
if not prop_name in resource:
continue
var old_value: Variant = resource.get(prop_name)
var target_type := typeof(old_value)
var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type)
resource.set(prop_name, new_value)
changed[prop_name] = {
"old": PropertyParser.serialize_value(old_value),
"new": PropertyParser.serialize_value(resource.get(prop_name)),
}
if changed.is_empty():
return success({"path": path, "changed": {}, "message": "No properties were changed"})
var err := ResourceSaver.save(resource, path)
if err != OK:
return error_internal("Failed to save resource: %s" % error_string(err))
return success({
"path": path,
"type": resource.get_class(),
"changed": changed,
})
func _create_resource(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var result2 := require_string(params, "type")
if result2[1] != null:
return result2[1]
var resource_type: String = result2[0]
if not ClassDB.class_exists(resource_type):
return error_invalid_params("Unknown resource type: %s" % resource_type)
if not ClassDB.is_parent_class(resource_type, "Resource"):
return error_invalid_params("'%s' is not a Resource type" % resource_type)
var overwrite: bool = optional_bool(params, "overwrite", false)
if FileAccess.file_exists(path) and not overwrite:
return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"})
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var resource: Resource = ClassDB.instantiate(resource_type)
if resource == null:
return error_internal("Failed to instantiate: %s" % resource_type)
# Apply properties
var properties: Dictionary = params.get("properties", {})
for prop_name: String in properties:
if prop_name in resource:
var current: Variant = resource.get(prop_name)
resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current)))
var err := ResourceSaver.save(resource, path)
if err != OK:
return error_internal("Failed to save resource: %s" % error_string(err))
# Rescan filesystem
EditorInterface.get_resource_filesystem().scan()
return success({
"path": path,
"type": resource_type,
"properties_set": properties.keys(),
})
func _get_resource_preview(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Resource '%s'" % path)
var max_size: int = optional_int(params, "max_size", 256)
var image: Image = null
# Try loading as image file directly
var ext := path.get_extension().to_lower()
if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]:
image = Image.new()
var err := image.load(path)
if err != OK:
return error_internal("Failed to load image: %s" % error_string(err))
else:
# Try loading as resource and extracting image
var resource: Resource = ResourceLoader.load(path)
if resource == null:
return error_internal("Failed to load resource: %s" % path)
if resource is Texture2D:
image = (resource as Texture2D).get_image()
elif resource is Image:
image = resource as Image
else:
return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class())
if image == null:
return error_internal("Could not extract image from resource")
# Resize if needed
if image.get_width() > max_size or image.get_height() > max_size:
var scale_x := float(max_size) / float(image.get_width())
var scale_y := float(max_size) / float(image.get_height())
var scale := minf(scale_x, scale_y)
var new_w := int(image.get_width() * scale)
var new_h := int(image.get_height() * scale)
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
"path": path,
})

View File

@@ -0,0 +1,398 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
## Editor-side commands for runtime game inspection.
## Communicates with MCPGameInspector autoload via file-based IPC.
func get_commands() -> Dictionary:
return {
"get_game_scene_tree": _get_game_scene_tree,
"get_game_node_properties": _get_game_node_properties,
"set_game_node_property": _set_game_node_property,
"capture_frames": _capture_frames,
"monitor_properties": _monitor_properties,
"execute_game_script": _execute_game_script,
"start_recording": _start_recording,
"stop_recording": _stop_recording,
"replay_recording": _replay_recording,
"find_nodes_by_script": _find_nodes_by_script,
"get_autoload": _get_autoload,
"batch_get_properties": _batch_get_properties,
"find_ui_elements": _find_ui_elements,
"click_button_by_text": _click_button_by_text,
"wait_for_node": _wait_for_node,
"find_nearby_nodes": _find_nearby_nodes,
"navigate_to": _navigate_to,
"move_to": _move_to,
"watch_signals": _watch_signals,
}
func _get_game_scene_tree(params: Dictionary) -> Dictionary:
var max_depth: int = optional_int(params, "max_depth", -1)
var cmd_params := {"max_depth": max_depth}
var script_filter: String = optional_string(params, "script_filter")
if not script_filter.is_empty():
cmd_params["script_filter"] = script_filter
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
var named_only: bool = optional_bool(params, "named_only", false)
if named_only:
cmd_params["named_only"] = true
return await _send_game_command("get_scene_tree", cmd_params)
func _get_game_node_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var cmd_params := {"node_path": result[0]}
# Optional property filter
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("get_node_properties", cmd_params)
func _set_game_node_property(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var prop_result := require_string(params, "property")
if prop_result[1] != null:
return prop_result[1]
if not params.has("value"):
return error_invalid_params("Missing required parameter: value")
return await _send_game_command("set_node_property", {
"node_path": result[0],
"property": prop_result[0],
"value": params["value"],
})
func _execute_game_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "code")
if result[1] != null:
return result[1]
return await _send_game_command("execute_script", {
"code": result[0],
}, 10.0)
func _capture_frames(params: Dictionary) -> Dictionary:
var count: int = optional_int(params, "count", 5)
var frame_interval: int = optional_int(params, "frame_interval", 10)
var half_resolution: bool = optional_bool(params, "half_resolution", true)
# Dynamic timeout: allow enough time for frame capture
# At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead
var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0
var timeout := minf(estimated_seconds, 25.0)
return await _send_game_command("capture_frames", {
"count": count,
"frame_interval": frame_interval,
"half_resolution": half_resolution,
}, timeout)
func _monitor_properties(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
if not params.has("properties") or not params["properties"] is Array:
return error_invalid_params("'properties' array is required")
var frame_count: int = optional_int(params, "frame_count", 60)
var frame_interval: int = optional_int(params, "frame_interval", 1)
# Dynamic timeout
var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0
var timeout := minf(estimated_seconds, 25.0)
return await _send_game_command("monitor_properties", {
"node_path": result[0],
"properties": params["properties"],
"frame_count": frame_count,
"frame_interval": frame_interval,
}, timeout)
func _start_recording(params: Dictionary) -> Dictionary:
return await _send_game_command("start_recording", {})
func _stop_recording(params: Dictionary) -> Dictionary:
return await _send_game_command("stop_recording", {}, 5.0)
func _replay_recording(params: Dictionary) -> Dictionary:
if not params.has("events") or not params["events"] is Array:
return error_invalid_params("'events' array is required")
var speed: float = float(params.get("speed", 1.0))
# Calculate timeout based on event duration
var max_time_ms: int = 0
for event_data: Dictionary in params["events"]:
var t: int = int(event_data.get("time_ms", 0))
if t > max_time_ms:
max_time_ms = t
var timeout := (max_time_ms / 1000.0 / speed) + 5.0
return await _send_game_command("replay_recording", {
"events": params["events"],
"speed": speed,
}, minf(timeout, 120.0))
func _find_nodes_by_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "script")
if result[1] != null:
return result[1]
var cmd_params := {"script": result[0]}
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("find_nodes_by_script", cmd_params)
func _get_autoload(params: Dictionary) -> Dictionary:
var result := require_string(params, "name")
if result[1] != null:
return result[1]
var cmd_params := {"name": result[0]}
if params.has("properties") and params["properties"] is Array:
cmd_params["properties"] = params["properties"]
return await _send_game_command("get_autoload", cmd_params)
func _batch_get_properties(params: Dictionary) -> Dictionary:
if not params.has("nodes") or not params["nodes"] is Array:
return error_invalid_params("'nodes' array is required")
return await _send_game_command("batch_get_properties", {
"nodes": params["nodes"],
})
func _find_ui_elements(params: Dictionary) -> Dictionary:
var cmd_params := {}
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
return await _send_game_command("find_ui_elements", cmd_params)
func _click_button_by_text(params: Dictionary) -> Dictionary:
var result := require_string(params, "text")
if result[1] != null:
return result[1]
var cmd_params := {"text": result[0]}
var partial: bool = optional_bool(params, "partial", true)
cmd_params["partial"] = partial
return await _send_game_command("click_button_by_text", cmd_params)
func _wait_for_node(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var timeout: float = float(params.get("timeout", 5.0))
var poll_frames: int = optional_int(params, "poll_frames", 5)
return await _send_game_command("wait_for_node", {
"node_path": result[0],
"timeout": timeout,
"poll_frames": poll_frames,
}, timeout + 2.0)
func _find_nearby_nodes(params: Dictionary) -> Dictionary:
if not params.has("position"):
return error_invalid_params("Missing required parameter: position")
var cmd_params: Dictionary = {"position": params["position"]}
if params.has("radius"):
cmd_params["radius"] = float(params["radius"])
var type_filter: String = optional_string(params, "type_filter")
if not type_filter.is_empty():
cmd_params["type_filter"] = type_filter
var group_filter: String = optional_string(params, "group_filter")
if not group_filter.is_empty():
cmd_params["group_filter"] = group_filter
if params.has("max_results"):
cmd_params["max_results"] = int(params["max_results"])
return await _send_game_command("find_nearby_nodes", cmd_params)
func _navigate_to(params: Dictionary) -> Dictionary:
if not params.has("target"):
return error_invalid_params("Missing required parameter: target")
var cmd_params: Dictionary = {"target": params["target"]}
var player_path: String = optional_string(params, "player_path")
if not player_path.is_empty():
cmd_params["player_path"] = player_path
var camera_path: String = optional_string(params, "camera_path")
if not camera_path.is_empty():
cmd_params["camera_path"] = camera_path
if params.has("move_speed"):
cmd_params["move_speed"] = float(params["move_speed"])
return await _send_game_command("navigate_to", cmd_params)
func _move_to(params: Dictionary) -> Dictionary:
if not params.has("target"):
return error_invalid_params("Missing required parameter: target")
var cmd_params: Dictionary = {"target": params["target"]}
var player_path: String = optional_string(params, "player_path")
if not player_path.is_empty():
cmd_params["player_path"] = player_path
var camera_path: String = optional_string(params, "camera_path")
if not camera_path.is_empty():
cmd_params["camera_path"] = camera_path
if params.has("arrival_radius"):
cmd_params["arrival_radius"] = float(params["arrival_radius"])
if params.has("timeout"):
cmd_params["timeout"] = float(params["timeout"])
if params.has("run"):
cmd_params["run"] = bool(params["run"])
if params.has("look_at_target"):
cmd_params["look_at_target"] = bool(params["look_at_target"])
# Dynamic timeout: game-side timeout + overhead for IPC polling
var game_timeout: float = float(params.get("timeout", 15.0))
var ipc_timeout: float = game_timeout + 5.0
return await _send_game_command("move_to", cmd_params, ipc_timeout)
func _watch_signals(params: Dictionary) -> Dictionary:
if not params.has("node_paths") or not params["node_paths"] is Array:
return error_invalid_params("Missing required parameter: node_paths (Array)")
var cmd_params: Dictionary = {"node_paths": params["node_paths"]}
if params.has("signal_filter") and params["signal_filter"] is Array:
cmd_params["signal_filter"] = params["signal_filter"]
var duration_ms: int = optional_int(params, "duration_ms", 5000)
cmd_params["duration_ms"] = duration_ms
# Dynamic timeout: duration + overhead
var timeout_sec: float = (duration_ms / 1000.0) + 5.0
return await _send_game_command("watch_signals", cmd_params, timeout_sec)
# ── IPC Helper ────────────────────────────────────────────────────────────────
func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
# Clean stale response
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)
# Write request
var request_data := JSON.stringify({"command": command, "params": params})
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create game request file")
req.store_string(request_data)
req.close()
# Poll for response
var attempts := int(timeout_sec / 0.1)
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
# Check if game is still running
if not ei.is_playing_scene():
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game stopped during command execution")
attempts -= 1
if not FileAccess.file_exists(response_path):
# Try to auto-resume the debugger (runtime error may have paused the game)
if ei.is_playing_scene():
_try_debugger_continue()
# Give the game a chance to recover and write a response
for _retry in 20:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
if not FileAccess.file_exists(response_path):
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
})
# Read response
var file := FileAccess.open(response_path, FileAccess.READ)
if file == null:
return error_internal("Could not read game response file")
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(response_path)
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
return error_internal("Invalid response JSON from game")
if parsed.has("error"):
return error(-32000, str(parsed["error"]))
return success(parsed)
## Press the debugger "Continue" button to resume a paused game process.
func _try_debugger_continue() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var queue: Array[Node] = [base]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
var inner: Array[Node] = [node]
while not inner.is_empty():
var n := inner.pop_front()
if n is Button and n.tooltip_text == "Continue":
n.emit_signal("pressed")
push_warning("[MCP] Auto-resumed debugger after runtime error")
return
for c in n.get_children():
inner.append(c)
return
for child in node.get_children():
queue.append(child)

View File

@@ -0,0 +1,680 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
func get_commands() -> Dictionary:
return {
"add_mesh_instance": _add_mesh_instance,
"setup_lighting": _setup_lighting,
"set_material_3d": _set_material_3d,
"setup_environment": _setup_environment,
"setup_camera_3d": _setup_camera_3d,
"add_gridmap": _add_gridmap,
}
## ─── Helpers ───────────────────────────────────────────────────────────────
func _optional_float(params: Dictionary, key: String, default: float) -> float:
if params.has(key):
return float(params[key])
return default
func _parse_color_param(params: Dictionary, key: String, default: Color) -> Color:
if not params.has(key):
return default
var val: Variant = params[key]
if val is String:
return PropertyParser.parse_value(val, TYPE_COLOR)
if val is Dictionary:
return Color(
float(val.get("r", default.r)),
float(val.get("g", default.g)),
float(val.get("b", default.b)),
float(val.get("a", default.a))
)
return default
func _parse_vector3_param(params: Dictionary, key: String, default: Vector3) -> Vector3:
if not params.has(key):
return default
var val: Variant = params[key]
if val is String:
return PropertyParser.parse_value(val, TYPE_VECTOR3)
if val is Dictionary:
return Vector3(
float(val.get("x", default.x)),
float(val.get("y", default.y)),
float(val.get("z", default.z))
)
if val is Array and val.size() >= 3:
return Vector3(float(val[0]), float(val[1]), float(val[2]))
return default
func _add_child_with_undo(node: Node, parent: Node, root: Node, action_name: String) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action(action_name)
undo_redo.add_do_method(parent, "add_child", node)
undo_redo.add_do_method(node, "set_owner", root)
undo_redo.add_do_reference(node)
undo_redo.add_undo_method(parent, "remove_child", node)
undo_redo.commit_action()
## ─── 1. add_mesh_instance ──────────────────────────────────────────────────
func _add_mesh_instance(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "MeshInstance3D")
var mesh_type: String = optional_string(params, "mesh_type", "")
var mesh_file: String = optional_string(params, "mesh_file", "")
if mesh_type.is_empty() and mesh_file.is_empty():
return error_invalid_params("Either 'mesh_type' or 'mesh_file' is required")
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = node_name
if not mesh_file.is_empty():
# Load .glb / .gltf / .obj
if not ResourceLoader.exists(mesh_file):
mesh_instance.queue_free()
return error_not_found("Mesh file '%s'" % mesh_file, "Provide a valid res:// path to .glb, .gltf, or .obj")
var loaded: Resource = load(mesh_file)
if loaded is Mesh:
mesh_instance.mesh = loaded as Mesh
elif loaded is PackedScene:
# For .glb/.gltf we instantiate and steal the first MeshInstance3D's mesh
var scene_instance: Node = (loaded as PackedScene).instantiate()
var found_mesh: Mesh = null
var search_nodes: Array[Node] = [scene_instance]
while not search_nodes.is_empty():
var n: Node = search_nodes.pop_front()
if n is MeshInstance3D and (n as MeshInstance3D).mesh != null:
found_mesh = (n as MeshInstance3D).mesh
break
for child in n.get_children():
search_nodes.append(child)
scene_instance.queue_free()
if found_mesh == null:
mesh_instance.queue_free()
return error_invalid_params("No mesh found in '%s'" % mesh_file)
mesh_instance.mesh = found_mesh
else:
mesh_instance.queue_free()
return error_invalid_params("'%s' is not a Mesh or PackedScene" % mesh_file)
else:
# Primitive mesh
var mesh_classes := {
"BoxMesh": BoxMesh,
"SphereMesh": SphereMesh,
"CylinderMesh": CylinderMesh,
"CapsuleMesh": CapsuleMesh,
"PlaneMesh": PlaneMesh,
"PrismMesh": PrismMesh,
"TorusMesh": TorusMesh,
"QuadMesh": QuadMesh,
}
if not mesh_classes.has(mesh_type):
mesh_instance.queue_free()
return error_invalid_params("Unknown mesh_type '%s'. Available: %s" % [mesh_type, mesh_classes.keys()])
var mesh_res: Mesh = mesh_classes[mesh_type].new()
# Apply mesh properties if provided
var mesh_properties: Dictionary = params.get("mesh_properties", {})
for prop_name: String in mesh_properties:
if prop_name in mesh_res:
var current: Variant = mesh_res.get(prop_name)
mesh_res.set(prop_name, PropertyParser.parse_value(mesh_properties[prop_name], typeof(current)))
mesh_instance.mesh = mesh_res
# Transform
var position := _parse_vector3_param(params, "position", Vector3.ZERO)
var rotation_deg := _parse_vector3_param(params, "rotation", Vector3.ZERO)
var scale_vec := _parse_vector3_param(params, "scale", Vector3.ONE)
mesh_instance.position = position
mesh_instance.rotation_degrees = rotation_deg
mesh_instance.scale = scale_vec
_add_child_with_undo(mesh_instance, parent, root, "MCP: Add MeshInstance3D")
return success({
"node_path": str(root.get_path_to(mesh_instance)),
"name": str(mesh_instance.name),
"mesh_type": mesh_type if mesh_file.is_empty() else mesh_file,
})
## ─── 2. setup_lighting ────────────────────────────────────────────────────
func _setup_lighting(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var light_type: String = optional_string(params, "light_type", "")
var preset: String = optional_string(params, "preset", "")
var node_name: String = optional_string(params, "name", "")
# Preset configurations
if not preset.is_empty():
match preset:
"sun":
light_type = "DirectionalLight3D"
if node_name.is_empty():
node_name = "SunLight"
"indoor":
light_type = "OmniLight3D"
if node_name.is_empty():
node_name = "IndoorLight"
"dramatic":
light_type = "SpotLight3D"
if node_name.is_empty():
node_name = "DramaticLight"
_:
return error_invalid_params("Unknown preset '%s'. Available: sun, indoor, dramatic" % preset)
if light_type.is_empty():
return error_invalid_params("Either 'light_type' or 'preset' is required")
var light: Light3D
match light_type:
"DirectionalLight3D":
light = DirectionalLight3D.new()
"OmniLight3D":
light = OmniLight3D.new()
"SpotLight3D":
light = SpotLight3D.new()
_:
return error_invalid_params("Unknown light_type '%s'. Available: DirectionalLight3D, OmniLight3D, SpotLight3D" % light_type)
if node_name.is_empty():
node_name = light_type
light.name = node_name
# Common properties
light.light_color = _parse_color_param(params, "color", Color.WHITE)
light.light_energy = _optional_float(params, "energy", 1.0)
light.shadow_enabled = optional_bool(params, "shadows", false)
# Type-specific properties
if light is OmniLight3D:
var omni: OmniLight3D = light as OmniLight3D
omni.omni_range = _optional_float(params, "range", 5.0)
omni.omni_attenuation = _optional_float(params, "attenuation", 1.0)
elif light is SpotLight3D:
var spot: SpotLight3D = light as SpotLight3D
spot.spot_range = _optional_float(params, "range", 5.0)
spot.spot_attenuation = _optional_float(params, "attenuation", 1.0)
spot.spot_angle = _optional_float(params, "spot_angle", 45.0)
spot.spot_angle_attenuation = _optional_float(params, "spot_angle_attenuation", 1.0)
# Apply preset defaults after type creation
if not preset.is_empty():
match preset:
"sun":
light.light_energy = _optional_float(params, "energy", 1.0)
light.shadow_enabled = optional_bool(params, "shadows", true)
light.rotation_degrees = _parse_vector3_param(params, "rotation", Vector3(-45, -30, 0))
"indoor":
light.light_energy = _optional_float(params, "energy", 0.8)
light.light_color = _parse_color_param(params, "color", Color(1.0, 0.95, 0.85))
if light is OmniLight3D:
(light as OmniLight3D).omni_range = _optional_float(params, "range", 8.0)
"dramatic":
light.light_energy = _optional_float(params, "energy", 2.0)
light.shadow_enabled = optional_bool(params, "shadows", true)
if light is SpotLight3D:
(light as SpotLight3D).spot_angle = _optional_float(params, "spot_angle", 25.0)
(light as SpotLight3D).spot_range = _optional_float(params, "range", 10.0)
# Position / rotation
light.position = _parse_vector3_param(params, "position", Vector3.ZERO)
if params.has("rotation"):
light.rotation_degrees = _parse_vector3_param(params, "rotation", light.rotation_degrees)
_add_child_with_undo(light, parent, root, "MCP: Add %s" % light_type)
return success({
"node_path": str(root.get_path_to(light)),
"name": str(light.name),
"light_type": light_type,
"preset": preset,
})
## ─── 3. set_material_3d ───────────────────────────────────────────────────
func _set_material_3d(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path)
if not node is MeshInstance3D:
return error_invalid_params("Node '%s' is not a MeshInstance3D (is %s)" % [node_path, node.get_class()])
var mesh_inst: MeshInstance3D = node as MeshInstance3D
var surface_index: int = optional_int(params, "surface_index", 0)
var mat := StandardMaterial3D.new()
# Albedo
mat.albedo_color = _parse_color_param(params, "albedo_color", Color.WHITE)
if params.has("albedo_texture"):
var tex_path: String = params["albedo_texture"]
if ResourceLoader.exists(tex_path):
mat.albedo_texture = load(tex_path) as Texture2D
# PBR
mat.metallic = _optional_float(params, "metallic", 0.0)
mat.roughness = _optional_float(params, "roughness", 1.0)
if params.has("metallic_texture"):
var tex_path: String = params["metallic_texture"]
if ResourceLoader.exists(tex_path):
mat.metallic_texture = load(tex_path) as Texture2D
if params.has("roughness_texture"):
var tex_path: String = params["roughness_texture"]
if ResourceLoader.exists(tex_path):
mat.roughness_texture = load(tex_path) as Texture2D
if params.has("normal_texture"):
mat.normal_enabled = true
var tex_path: String = params["normal_texture"]
if ResourceLoader.exists(tex_path):
mat.normal_texture = load(tex_path) as Texture2D
# Emission
if params.has("emission") or params.has("emission_color"):
mat.emission_enabled = true
mat.emission = _parse_color_param(params, "emission", _parse_color_param(params, "emission_color", Color.BLACK))
mat.emission_energy_multiplier = _optional_float(params, "emission_energy", 1.0)
if params.has("emission_texture"):
mat.emission_enabled = true
var tex_path: String = params["emission_texture"]
if ResourceLoader.exists(tex_path):
mat.emission_texture = load(tex_path) as Texture2D
# Transparency
if params.has("transparency"):
var transparency_val: String = str(params["transparency"])
match transparency_val.to_upper():
"DISABLED", "0":
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
"ALPHA", "1":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
"ALPHA_SCISSOR", "2":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
"ALPHA_HASH", "3":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH
"ALPHA_DEPTH_PRE_PASS", "4":
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS
# Cull mode
if params.has("cull_mode"):
var cull_val: String = str(params["cull_mode"])
match cull_val.to_upper():
"BACK", "0":
mat.cull_mode = BaseMaterial3D.CULL_BACK
"FRONT", "1":
mat.cull_mode = BaseMaterial3D.CULL_FRONT
"DISABLED", "2":
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
# Apply
var old_mat: Material = mesh_inst.get_surface_override_material(surface_index)
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set material on %s" % mesh_inst.name)
undo_redo.add_do_method(mesh_inst, "set_surface_override_material", surface_index, mat)
undo_redo.add_undo_method(mesh_inst, "set_surface_override_material", surface_index, old_mat)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(mesh_inst)),
"surface_index": surface_index,
"albedo_color": str(mat.albedo_color),
"metallic": mat.metallic,
"roughness": mat.roughness,
})
## ─── 4. setup_environment ─────────────────────────────────────────────────
func _setup_environment(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "WorldEnvironment")
# Check if a WorldEnvironment already exists at the target
var node_path: String = optional_string(params, "node_path", "")
var world_env: WorldEnvironment = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is WorldEnvironment:
world_env = existing as WorldEnvironment
is_existing = true
if world_env == null:
world_env = WorldEnvironment.new()
world_env.name = node_name
var env: Environment = world_env.environment
if env == null:
env = Environment.new()
# Background / Sky
var bg_mode: String = optional_string(params, "background_mode", "sky")
match bg_mode.to_lower():
"sky":
env.background_mode = Environment.BG_SKY
"color":
env.background_mode = Environment.BG_COLOR
env.background_color = _parse_color_param(params, "background_color", Color(0.3, 0.3, 0.3))
"canvas":
env.background_mode = Environment.BG_CANVAS
"clear_color":
env.background_mode = Environment.BG_CLEAR_COLOR
# Procedural sky
if params.has("sky") and params["sky"] is Dictionary:
var sky_params: Dictionary = params["sky"]
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = _parse_color_param(sky_params, "sky_top_color", Color(0.385, 0.454, 0.55))
sky_mat.sky_horizon_color = _parse_color_param(sky_params, "sky_horizon_color", Color(0.646, 0.654, 0.67))
sky_mat.ground_bottom_color = _parse_color_param(sky_params, "ground_bottom_color", Color(0.2, 0.169, 0.133))
sky_mat.ground_horizon_color = _parse_color_param(sky_params, "ground_horizon_color", Color(0.646, 0.654, 0.67))
sky_mat.sun_angle_max = _optional_float(sky_params, "sun_angle_max", 30.0) if sky_params.has("sun_angle_max") else 30.0
sky_mat.sky_curve = _optional_float(sky_params, "sky_curve", 0.15) if sky_params.has("sky_curve") else 0.15
var sky := Sky.new()
sky.sky_material = sky_mat
env.sky = sky
env.background_mode = Environment.BG_SKY
# Ambient light
if params.has("ambient_light_color"):
env.ambient_light_color = _parse_color_param(params, "ambient_light_color", Color.WHITE)
env.ambient_light_energy = _optional_float(params, "ambient_light_energy", 1.0) if params.has("ambient_light_energy") else env.ambient_light_energy
if params.has("ambient_light_source"):
var src: String = str(params["ambient_light_source"])
match src.to_upper():
"BACKGROUND", "0":
env.ambient_light_source = Environment.AMBIENT_SOURCE_BG
"DISABLED", "1":
env.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED
"COLOR", "2":
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
"SKY", "3":
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
# Tonemap
if params.has("tonemap_mode"):
var tm: String = str(params["tonemap_mode"])
match tm.to_upper():
"LINEAR", "0":
env.tonemap_mode = Environment.TONE_MAPPER_LINEAR
"REINHARDT", "1":
env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT
"FILMIC", "2":
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
"ACES", "3":
env.tonemap_mode = Environment.TONE_MAPPER_ACES
"AGX", "4":
env.tonemap_mode = 4 # Environment.TONE_MAPPER_AGX (Godot 4.4+)
if params.has("tonemap_exposure"):
env.tonemap_exposure = _optional_float(params, "tonemap_exposure", 1.0)
if params.has("tonemap_white"):
env.tonemap_white = _optional_float(params, "tonemap_white", 1.0)
# Fog
if params.has("fog_enabled"):
env.fog_enabled = optional_bool(params, "fog_enabled", false)
if env.fog_enabled or params.has("fog_light_color"):
env.fog_light_color = _parse_color_param(params, "fog_light_color", Color(0.518, 0.553, 0.608))
env.fog_density = _optional_float(params, "fog_density", 0.01) if params.has("fog_density") else env.fog_density
env.fog_light_energy = _optional_float(params, "fog_light_energy", 1.0) if params.has("fog_light_energy") else env.fog_light_energy
# Glow
if params.has("glow_enabled"):
env.glow_enabled = optional_bool(params, "glow_enabled", false)
if env.glow_enabled:
env.glow_intensity = _optional_float(params, "glow_intensity", 0.8) if params.has("glow_intensity") else env.glow_intensity
env.glow_strength = _optional_float(params, "glow_strength", 1.0) if params.has("glow_strength") else env.glow_strength
env.glow_bloom = _optional_float(params, "glow_bloom", 0.0) if params.has("glow_bloom") else env.glow_bloom
# SSAO
if params.has("ssao_enabled"):
env.ssao_enabled = optional_bool(params, "ssao_enabled", false)
if env.ssao_enabled:
env.ssao_radius = _optional_float(params, "ssao_radius", 1.0) if params.has("ssao_radius") else env.ssao_radius
env.ssao_intensity = _optional_float(params, "ssao_intensity", 2.0) if params.has("ssao_intensity") else env.ssao_intensity
# SSR
if params.has("ssr_enabled"):
env.ssr_enabled = optional_bool(params, "ssr_enabled", false)
if env.ssr_enabled:
env.ssr_max_steps = optional_int(params, "ssr_max_steps", 64) if params.has("ssr_max_steps") else env.ssr_max_steps
env.ssr_fade_in = _optional_float(params, "ssr_fade_in", 0.15) if params.has("ssr_fade_in") else env.ssr_fade_in
env.ssr_fade_out = _optional_float(params, "ssr_fade_out", 2.0) if params.has("ssr_fade_out") else env.ssr_fade_out
# SDFGI
if params.has("sdfgi_enabled"):
env.sdfgi_enabled = optional_bool(params, "sdfgi_enabled", false)
world_env.environment = env
if not is_existing:
_add_child_with_undo(world_env, parent, root, "MCP: Add WorldEnvironment")
var features: Array = []
if env.fog_enabled: features.append("fog")
if env.glow_enabled: features.append("glow")
if env.ssao_enabled: features.append("ssao")
if env.ssr_enabled: features.append("ssr")
if env.sdfgi_enabled: features.append("sdfgi")
return success({
"node_path": str(root.get_path_to(world_env)),
"name": str(world_env.name),
"background_mode": bg_mode,
"features": features,
"is_existing": is_existing,
})
## ─── 5. setup_camera_3d ──────────────────────────────────────────────────
func _setup_camera_3d(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
# Check if we're configuring an existing camera
var node_path: String = optional_string(params, "node_path", "")
var camera: Camera3D = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is Camera3D:
camera = existing as Camera3D
is_existing = true
elif existing != null:
return error_invalid_params("Node '%s' is not a Camera3D (is %s)" % [node_path, existing.get_class()])
if camera == null:
camera = Camera3D.new()
camera.name = optional_string(params, "name", "Camera3D")
# Projection
var projection_str: String = optional_string(params, "projection", "")
if not projection_str.is_empty():
match projection_str.to_lower():
"perspective", "0":
camera.projection = Camera3D.PROJECTION_PERSPECTIVE
"orthogonal", "orthographic", "1":
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
"frustum", "2":
camera.projection = Camera3D.PROJECTION_FRUSTUM
# Properties
if params.has("fov"):
camera.fov = _optional_float(params, "fov", 75.0)
if params.has("size"):
camera.size = _optional_float(params, "size", 1.0)
if params.has("near"):
camera.near = _optional_float(params, "near", 0.05)
if params.has("far"):
camera.far = _optional_float(params, "far", 4000.0)
if params.has("cull_mask"):
camera.cull_mask = optional_int(params, "cull_mask", 1048575)
# Make current
camera.current = optional_bool(params, "current", false)
# Transform
camera.position = _parse_vector3_param(params, "position", camera.position if is_existing else Vector3(0, 1, 3))
if params.has("rotation"):
camera.rotation_degrees = _parse_vector3_param(params, "rotation", camera.rotation_degrees)
if params.has("look_at"):
var target := _parse_vector3_param(params, "look_at", Vector3.ZERO)
# We need to set position first, then use look_at
camera.look_at(target)
# Environment override
if params.has("environment_path"):
var env_path: String = params["environment_path"]
if ResourceLoader.exists(env_path):
var env_res: Resource = load(env_path)
if env_res is Environment:
camera.environment = env_res as Environment
if not is_existing:
_add_child_with_undo(camera, parent, root, "MCP: Add Camera3D")
return success({
"node_path": str(root.get_path_to(camera)),
"name": str(camera.name),
"projection": "perspective" if camera.projection == Camera3D.PROJECTION_PERSPECTIVE else "orthogonal",
"fov": camera.fov,
"position": str(camera.position),
"is_existing": is_existing,
})
## ─── 6. add_gridmap ──────────────────────────────────────────────────────
func _add_gridmap(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var parent_path: String = optional_string(params, "parent_path", ".")
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path)
var node_name: String = optional_string(params, "name", "GridMap")
# Check for existing GridMap to configure
var node_path: String = optional_string(params, "node_path", "")
var gridmap: GridMap = null
var is_existing := false
if not node_path.is_empty():
var existing := find_node_by_path(node_path)
if existing != null and existing is GridMap:
gridmap = existing as GridMap
is_existing = true
elif existing != null:
return error_invalid_params("Node '%s' is not a GridMap (is %s)" % [node_path, existing.get_class()])
if gridmap == null:
gridmap = GridMap.new()
gridmap.name = node_name
# Mesh library
if params.has("mesh_library_path"):
var lib_path: String = params["mesh_library_path"]
if not ResourceLoader.exists(lib_path):
if not is_existing:
gridmap.queue_free()
return error_not_found("MeshLibrary '%s'" % lib_path, "Provide a valid res:// path to a .meshlib or .tres file")
var lib: Resource = load(lib_path)
if lib is MeshLibrary:
gridmap.mesh_library = lib as MeshLibrary
else:
if not is_existing:
gridmap.queue_free()
return error_invalid_params("'%s' is not a MeshLibrary" % lib_path)
# Cell size
if params.has("cell_size"):
gridmap.cell_size = _parse_vector3_param(params, "cell_size", Vector3(2, 2, 2))
# Position
gridmap.position = _parse_vector3_param(params, "position", gridmap.position if is_existing else Vector3.ZERO)
if not is_existing:
_add_child_with_undo(gridmap, parent, root, "MCP: Add GridMap")
# Set cells
var cells: Array = params.get("cells", [])
var cells_set: int = 0
for cell in cells:
if cell is Dictionary:
var x: int = int(cell.get("x", 0))
var y: int = int(cell.get("y", 0))
var z: int = int(cell.get("z", 0))
var item: int = int(cell.get("item", 0))
var orientation: int = int(cell.get("orientation", 0))
gridmap.set_cell_item(Vector3i(x, y, z), item, orientation)
cells_set += 1
return success({
"node_path": str(root.get_path_to(gridmap)),
"name": str(gridmap.name),
"cells_set": cells_set,
"is_existing": is_existing,
"has_mesh_library": gridmap.mesh_library != null,
})

View File

@@ -0,0 +1,331 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
func get_commands() -> Dictionary:
return {
"get_scene_tree": _get_scene_tree,
"get_scene_file_content": _get_scene_file_content,
"create_scene": _create_scene,
"open_scene": _open_scene,
"delete_scene": _delete_scene,
"add_scene_instance": _add_scene_instance,
"play_scene": _play_scene,
"stop_scene": _stop_scene,
"save_scene": _save_scene,
"get_scene_exports": _get_scene_exports,
}
func _get_scene_tree(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var max_depth: int = optional_int(params, "max_depth", -1)
var tree := NodeUtils.get_node_tree(root, max_depth)
return success({"scene_path": root.scene_file_path, "tree": tree})
func _get_scene_file_content(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read file: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
return success({"path": path, "content": content, "size": content.length()})
func _create_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var guard := guard_offline_scene_save(path)
if not guard.is_empty():
return guard
var root_type: String = optional_string(params, "root_type", "Node2D")
var root_name: String = optional_string(params, "root_name", "")
# Validate root type exists
if not ClassDB.class_exists(root_type):
return error_invalid_params("Unknown node type: %s" % root_type)
# Create the scene
var root: Node = ClassDB.instantiate(root_type)
if root_name.is_empty():
root_name = path.get_file().get_basename()
root.name = root_name
var scene := PackedScene.new()
var err := scene.pack(root)
root.queue_free()
if err != OK:
return error_internal("Failed to pack scene: %s" % error_string(err))
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
err = ResourceSaver.save(scene, path)
if err != OK:
return error_internal("Failed to save scene: %s" % error_string(err))
# Refresh filesystem
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "root_type": root_type, "root_name": root_name})
func _open_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
EditorInterface.open_scene_from_path(path)
return success({"path": path, "opened": true})
func _delete_scene(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var err := DirAccess.remove_absolute(path)
if err != OK:
return error_internal("Failed to delete scene: %s" % error_string(err))
# Also remove .import file if exists
var import_path := path + ".import"
if FileAccess.file_exists(import_path):
DirAccess.remove_absolute(import_path)
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "deleted": true})
func _add_scene_instance(params: Dictionary) -> Dictionary:
var result := require_string(params, "scene_path")
if result[1] != null:
return result[1]
var scene_path: String = result[0]
var parent_path: String = optional_string(params, "parent_path", ".")
var instance_name: String = optional_string(params, "name", "")
var root := get_edited_root()
if root == null:
return error_no_scene()
if not FileAccess.file_exists(scene_path):
return error_not_found("Scene file '%s'" % scene_path)
var parent := find_node_by_path(parent_path)
if parent == null:
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
var packed: PackedScene = load(scene_path)
if packed == null:
return error_internal("Failed to load scene: %s" % scene_path)
var instance := packed.instantiate()
if not instance_name.is_empty():
instance.name = instance_name
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Add scene instance")
undo_redo.add_do_method(parent, "add_child", instance)
undo_redo.add_do_method(instance, "set_owner", root)
undo_redo.add_do_reference(instance)
undo_redo.add_undo_method(parent, "remove_child", instance)
undo_redo.commit_action()
NodeUtils.set_owner_recursive(instance, root)
return success({
"node_path": str(root.get_path_to(instance)),
"scene_path": scene_path,
"name": instance.name,
})
func _play_scene(params: Dictionary) -> Dictionary:
var mode: String = optional_string(params, "mode", "main") # "main", "current", or path
match mode:
"main":
EditorInterface.play_main_scene()
"current":
EditorInterface.play_current_scene()
_:
# Treat as scene path
if not FileAccess.file_exists(mode):
return error_not_found("Scene file '%s'" % mode)
EditorInterface.play_custom_scene(mode)
return success({"playing": true, "mode": mode})
func _stop_scene(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return success({"stopped": false, "message": "No scene is currently playing"})
EditorInterface.stop_playing_scene()
# Clean up temp files
_cleanup_screenshot_files()
_cleanup_input_files()
_cleanup_inspector_files()
return success({"stopped": true})
func _save_scene(params: Dictionary) -> Dictionary:
var root := get_edited_root()
if root == null:
return error_no_scene()
var path: String = optional_string(params, "path", "")
if path.is_empty():
path = root.scene_file_path
if path.is_empty():
return error_invalid_params("No save path specified and scene has no existing path")
var normalized_path := normalize_project_path(path)
if is_scene_path_open(normalized_path) and not is_active_scene_path(normalized_path):
return error_conflict(
"Refusing to save inactive open scene '%s' from the active editor scene" % normalized_path,
{
"path": normalized_path,
"active_scene": normalize_project_path(root.scene_file_path),
"open_scenes": get_open_scene_paths(),
"suggestion": "Open the target scene tab before saving it, or close it before offline edits.",
}
)
var dir_path := normalized_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var err: int
var save_method: String
if root.scene_file_path.is_empty() or normalize_project_path(root.scene_file_path) != normalized_path:
EditorInterface.save_scene_as(normalized_path)
err = OK
save_method = "EditorInterface.save_scene_as"
else:
err = EditorInterface.save_scene()
save_method = "EditorInterface.save_scene"
if err != OK:
return error_internal("Failed to save scene via %s: %s" % [save_method, error_string(err)])
return success({"path": normalized_path, "saved": true, "method": save_method})
func _get_scene_exports(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Scene file '%s'" % path)
var packed: PackedScene = load(path)
if packed == null:
return error_internal("Failed to load scene: %s" % path)
var instance: Node = packed.instantiate()
if instance == null:
return error_internal("Failed to instantiate scene: %s" % path)
var nodes_data: Array = []
_collect_exports_recursive(instance, instance, nodes_data)
instance.queue_free()
return success({
"path": path,
"nodes": nodes_data,
"count": nodes_data.size(),
})
func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void:
var script: Script = node.get_script()
if script != null:
var exports: Dictionary = {}
for prop_info in script.get_script_property_list():
var usage: int = prop_info["usage"]
if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
var prop_name: String = prop_info["name"]
exports[prop_name] = {
"value": PropertyParser.serialize_value(node.get(prop_name)),
"type": prop_info["type"],
"hint": prop_info.get("hint", 0),
"hint_string": prop_info.get("hint_string", ""),
}
if not exports.is_empty():
var node_path := "." if node == root else str(root.get_path_to(node))
nodes_data.append({
"node_path": node_path,
"node_name": node.name,
"node_type": node.get_class(),
"script_path": script.resource_path,
"exports": exports,
})
for child in node.get_children():
_collect_exports_recursive(child, root, nodes_data)
func _cleanup_screenshot_files() -> void:
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_screenshot_request"
var screenshot_path := user_dir + "/mcp_screenshot.png"
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)
func _cleanup_input_files() -> void:
var user_dir := get_game_user_dir()
var commands_path := user_dir + "/mcp_input_commands"
if FileAccess.file_exists(commands_path):
DirAccess.remove_absolute(commands_path)
func _cleanup_inspector_files() -> void:
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)

View File

@@ -0,0 +1,369 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"list_scripts": _list_scripts,
"read_script": _read_script,
"create_script": _create_script,
"edit_script": _edit_script,
"attach_script": _attach_script,
"get_open_scripts": _get_open_scripts,
"validate_script": _validate_script,
}
func _guard_script_file_path(path: String, operation: String) -> Dictionary:
var ext := path.get_extension().to_lower()
if ext in ["gd", "cs"]:
return {}
return error(
-32602,
"%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)],
{
"path": normalize_project_path(path),
"extension": ext,
"suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.",
}
)
func _list_scripts(params: Dictionary) -> Dictionary:
var path: String = optional_string(params, "path", "res://")
var recursive: bool = optional_bool(params, "recursive", true)
var scripts: Array = []
_find_scripts(path, recursive, scripts)
return success({"scripts": scripts, "count": scripts.size()})
func _find_scripts(path: String, recursive: bool, scripts: Array) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := path.path_join(file_name)
if dir.current_is_dir():
if recursive:
_find_scripts(full_path, recursive, scripts)
elif file_name.get_extension() in ["gd", "cs", "gdshader"]:
var info := {"path": full_path, "type": file_name.get_extension()}
# Get basic file info
var file := FileAccess.open(full_path, FileAccess.READ)
if file:
info["size"] = file.get_length()
# Read first line for class/extends info
var first_line := file.get_line().strip_edges()
if first_line.begins_with("class_name "):
info["class_name"] = first_line.substr(11).strip_edges()
elif first_line.begins_with("extends "):
info["extends"] = first_line.substr(8).strip_edges()
file.close()
scripts.append(info)
file_name = dir.get_next()
dir.list_dir_end()
func _read_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
var line_count := content.count("\n") + 1
file.close()
return success({
"path": path,
"content": content,
"line_count": line_count,
"size": content.length(),
})
func _create_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "create_script")
if not path_guard.is_empty():
return path_guard
var content: String = optional_string(params, "content", "")
var base_class: String = optional_string(params, "extends", "Node")
var class_name_str: String = optional_string(params, "class_name", "")
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
# Generate template if no content provided
if content.is_empty():
var lines: PackedStringArray = []
if not class_name_str.is_empty():
lines.append("class_name %s" % class_name_str)
lines.append("extends %s" % base_class)
lines.append("")
lines.append("")
lines.append("func _ready() -> void:")
lines.append("\tpass")
lines.append("")
content = "\n".join(lines)
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
EditorInterface.get_resource_filesystem().scan()
# Pre-load so the script is available immediately
if ResourceLoader.exists(path):
var script = load(path)
if script is Script:
script.reload(true)
return success({"path": path, "created": true})
func _edit_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "edit_script")
if not path_guard.is_empty():
return path_guard
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
# Read current content
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
var changes_made := 0
# Support search-and-replace
if params.has("replacements") and params["replacements"] is Array:
var replacements: Array = params["replacements"]
for replacement in replacements:
if replacement is Dictionary:
var search: String = replacement.get("search", "")
var replace: String = replacement.get("replace", "")
if not search.is_empty():
var use_regex: bool = replacement.get("regex", false)
if use_regex:
var regex := RegEx.new()
var err := regex.compile(search)
if err == OK:
var new_content := regex.sub(content, replace, true)
if new_content != content:
content = new_content
changes_made += 1
else:
if content.contains(search):
content = content.replace(search, replace)
changes_made += 1
# Support 1-based inclusive line range replacement
elif params.has("content") and (params.has("start_line") or params.has("end_line")):
if not params.has("start_line"):
return error_invalid_params("start_line is required when end_line is provided")
var start_line: int = int(params["start_line"])
var end_line: int = int(params.get("end_line", start_line))
var lines := content.split("\n")
if start_line < 1:
return error_invalid_params("start_line must be >= 1")
if end_line < start_line:
return error_invalid_params("end_line must be >= start_line")
if start_line > lines.size():
return error_invalid_params("start_line is beyond the end of the file")
if end_line > lines.size():
return error_invalid_params("end_line is beyond the end of the file")
var replacement_lines := str(params["content"]).split("\n")
var start_index := start_line - 1
var remove_count := end_line - start_line + 1
for _i in range(remove_count):
lines.remove_at(start_index)
for i in range(replacement_lines.size()):
lines.insert(start_index + i, replacement_lines[i])
content = "\n".join(lines)
changes_made = 1
# Support full content replacement
elif params.has("content"):
content = str(params["content"])
changes_made = 1
# Support insert at line
elif params.has("insert_at_line") and params.has("text"):
var line_num: int = int(params["insert_at_line"])
var text: String = str(params["text"])
var lines := content.split("\n")
line_num = clampi(line_num, 0, lines.size())
lines.insert(line_num, text)
content = "\n".join(lines)
changes_made = 1
if changes_made == 0:
return success({"path": path, "changes_made": 0, "message": "No changes applied"})
# Write back
file = FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
# Reload the script resource so the editor picks up changes immediately
_reload_script(path)
return success({"path": path, "changes_made": changes_made})
## Force-reload a script so the editor reflects disk changes immediately.
func _reload_script(path: String) -> void:
# First, trigger a filesystem scan so Godot knows the file changed
EditorInterface.get_resource_filesystem().scan()
# If the script is already loaded in memory, reload it
if ResourceLoader.exists(path):
var script = load(path)
if script is Script:
script.reload(true)
# If the script is open in the script editor, the reload above updates it.
# But we also need to notify the editor to refresh its error indicators.
EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED)
func _attach_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "script_path")
if result2[1] != null:
return result2[1]
var script_path: String = result2[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
if not FileAccess.file_exists(script_path):
return error_not_found("Script '%s'" % script_path)
var script: Script = load(script_path)
if script == null:
return error_internal("Failed to load script: %s" % script_path)
var old_script: Variant = node.get_script()
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Attach script to %s" % node.name)
undo_redo.add_do_method(node, "set_script", script)
undo_redo.add_undo_method(node, "set_script", old_script)
undo_redo.commit_action()
return success({
"node_path": str(root.get_path_to(node)),
"script_path": script_path,
"attached": true,
})
func _validate_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var path_guard := _guard_script_file_path(path, "validate_script")
if not path_guard.is_empty():
return path_guard
if not FileAccess.file_exists(path):
return error_not_found("Script '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
var source_code := file.get_as_text()
file.close()
var script := GDScript.new()
script.source_code = source_code
var err := script.reload()
if err == OK:
return success({"path": path, "valid": true, "message": "Script compiles successfully"})
return success({
"path": path,
"valid": false,
"error_code": err,
"error_string": error_string(err),
"message": "Compilation failed. Use get_output_log or get_editor_errors for details.",
})
func _get_open_scripts(_params: Dictionary) -> Dictionary:
var script_editor := EditorInterface.get_script_editor()
var open_scripts: Array = []
for script_base in script_editor.get_open_scripts():
var info := {
"path": script_base.resource_path,
"type": script_base.get_class(),
}
open_scripts.append(info)
return success({"scripts": open_scripts, "count": open_scripts.size()})

View File

@@ -0,0 +1,239 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_shader": _create_shader,
"read_shader": _read_shader,
"edit_shader": _edit_shader,
"assign_shader_material": _assign_shader_material,
"set_shader_param": _set_shader_param,
"get_shader_params": _get_shader_params,
}
func _create_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var content: String = optional_string(params, "content", "")
var shader_type: String = optional_string(params, "shader_type", "spatial")
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
if content.is_empty():
match shader_type:
"spatial":
content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n"
"canvas_item":
content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n"
"particles":
content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n"
"sky":
content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n"
# Ensure directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
_refresh_loaded_shader(path, content)
return success({"path": path, "shader_type": shader_type, "created": true})
func _read_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Shader '%s'" % path)
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error()))
var content := file.get_as_text()
file.close()
return success({"path": path, "content": content, "size": content.length()})
func _refresh_loaded_shader(path: String, content: String) -> void:
var normalized := normalize_project_path(path)
if normalized.is_empty():
return
if ResourceLoader.has_cached(normalized):
var shader := Shader.new()
shader.code = content
shader.take_over_path(normalized)
shader.emit_changed()
EditorInterface.get_resource_filesystem().update_file(normalized)
func _edit_shader(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
if not FileAccess.file_exists(path):
return error_not_found("Shader '%s'" % path)
var force: bool = optional_bool(params, "force", false)
var guard := guard_text_resource_write(path, force)
if not guard.is_empty():
return guard
var changes_made := 0
var content := ""
if params.has("content"):
content = str(params["content"])
changes_made = 1
elif params.has("replacements") and params["replacements"] is Array:
# Read current
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return error_internal("Cannot read shader")
content = file.get_as_text()
file.close()
for replacement in params["replacements"]:
if replacement is Dictionary:
var search: String = replacement.get("search", "")
var replace: String = replacement.get("replace", "")
if not search.is_empty() and content.contains(search):
content = content.replace(search, replace)
changes_made += 1
if changes_made > 0:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return error_internal("Cannot write shader: %s" % error_string(FileAccess.get_open_error()))
file.store_string(content)
file.close()
_refresh_loaded_shader(path, content)
return success({"path": path, "changes_made": changes_made})
func _assign_shader_material(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "shader_path")
if result2[1] != null:
return result2[1]
var shader_path: String = result2[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
if not ResourceLoader.exists(shader_path):
return error_not_found("Shader '%s'" % shader_path)
var shader: Shader = load(shader_path)
if shader == null:
return error_internal("Failed to load shader")
var material := ShaderMaterial.new()
material.shader = shader
if node is CanvasItem:
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
elif node is MeshInstance3D:
set_property_with_undo(node, "material_override", material, "MCP: Assign shader material")
else:
# Try generic material property
if "material" in node:
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
else:
return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()])
return success({"node_path": node_path, "shader_path": shader_path, "assigned": true})
func _set_shader_param(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "param")
if result2[1] != null:
return result2[1]
var param_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var material: ShaderMaterial = null
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
material = (node as CanvasItem).material
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
material = (node as MeshInstance3D).material_override
if material == null:
return error(-32000, "Node has no ShaderMaterial")
var value = params.get("value")
if value is String:
var s: String = value
var expr := Expression.new()
if expr.parse(s) == OK:
var parsed = expr.execute()
if parsed != null:
value = parsed
material.set_shader_parameter(param_name, value)
return success({"node_path": node_path, "param": param_name, "value": str(value)})
func _get_shader_params(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node at '%s'" % node_path)
var material: ShaderMaterial = null
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
material = (node as CanvasItem).material
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
material = (node as MeshInstance3D).material_override
if material == null:
return error(-32000, "Node has no ShaderMaterial")
var shader_params: Dictionary = {}
for prop in material.get_property_list():
var pname: String = prop["name"]
if pname.begins_with("shader_parameter/"):
var key := pname.substr(17)
shader_params[key] = str(material.get(pname))
return success({"node_path": node_path, "params": shader_params})

View File

@@ -0,0 +1,580 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
## Test automation framework tools.
## Editor-side orchestration + runtime assertions via file-based IPC.
func get_commands() -> Dictionary:
return {
"run_test_scenario": _run_test_scenario,
"assert_node_state": _assert_node_state,
"assert_screen_text": _assert_screen_text,
"run_stress_test": _run_stress_test,
"get_test_report": _get_test_report,
}
# ── Internal test result accumulator ──────────────────────────────────────────
var _test_results: Array[Dictionary] = []
# ── Commands ──────────────────────────────────────────────────────────────────
func _run_test_scenario(params: Dictionary) -> Dictionary:
## Execute a test scenario: optionally play a scene, run a sequence of steps
## (input simulation, waits, assertions, screenshots), return pass/fail results.
##
## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}]
## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str}
## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float}
## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str}
## - screenshot: {type:"screenshot"} — captures a frame for visual inspection
if not params.has("steps") or not params["steps"] is Array:
return error_invalid_params("Missing required parameter: steps (Array)")
var steps: Array = params["steps"]
if steps.is_empty():
return error_invalid_params("Steps array is empty")
var scene_path: String = optional_string(params, "scene_path")
var ei := get_editor()
# Play scene if requested
if not scene_path.is_empty():
if ei.is_playing_scene():
ei.stop_playing_scene()
await get_tree().create_timer(0.5).timeout
if scene_path == "main":
ei.play_main_scene()
elif scene_path == "current":
ei.play_current_scene()
else:
if not FileAccess.file_exists(scene_path):
return error_not_found("Scene file '%s'" % scene_path)
ei.play_custom_scene(scene_path)
# Wait for game to start
await get_tree().create_timer(1.0).timeout
# Verify game is running
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {
"suggestion": "Provide scene_path or use play_scene first"
})
var results: Array[Dictionary] = []
var pass_count: int = 0
var fail_count: int = 0
var error_count: int = 0
for i in steps.size():
var step: Dictionary = steps[i]
if not step.has("type"):
results.append({"step": i, "error": "Missing 'type' field"})
error_count += 1
continue
var step_type: String = str(step["type"])
var step_result: Dictionary = {"step": i, "type": step_type}
match step_type:
"input":
var input_result := await _execute_input_step(step)
step_result.merge(input_result)
"wait":
var wait_result := await _execute_wait_step(step)
step_result.merge(wait_result)
"assert":
var assert_result := await _execute_assert_step(step)
step_result.merge(assert_result)
if assert_result.get("passed", false):
pass_count += 1
else:
fail_count += 1
"screenshot":
var screenshot_result := await _send_game_command("capture_frames", {
"count": 1,
"frame_interval": 1,
"half_resolution": optional_bool(step, "half_resolution", true),
}, 5.0)
if screenshot_result.has("result"):
step_result["captured"] = true
else:
step_result["captured"] = false
step_result["error"] = "Screenshot capture failed"
error_count += 1
_:
step_result["error"] = "Unknown step type: %s" % step_type
error_count += 1
results.append(step_result)
# Check if game crashed between steps
if not ei.is_playing_scene():
results.append({"step": i + 1, "error": "Game stopped unexpectedly"})
error_count += 1
break
var summary := {
"total_steps": steps.size(),
"completed_steps": results.size(),
"assertions_passed": pass_count,
"assertions_failed": fail_count,
"errors": error_count,
"all_passed": fail_count == 0 and error_count == 0,
"results": results,
}
# Store results for get_test_report
_test_results.append_array(results)
return success(summary)
func _assert_node_state(params: Dictionary) -> Dictionary:
## Assert a node's property equals expected value in the running game.
## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is.
## Returns pass/fail with actual value.
var path_result := require_string(params, "node_path")
if path_result[1] != null:
return path_result[1]
var prop_result := require_string(params, "property")
if prop_result[1] != null:
return prop_result[1]
if not params.has("expected"):
return error_invalid_params("Missing required parameter: expected")
var operator: String = optional_string(params, "operator", "eq")
var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]
if operator not in valid_operators:
return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)])
var result := await _send_game_command("assert_node_state", {
"node_path": path_result[0],
"property": prop_result[0],
"expected": params["expected"],
"operator": operator,
}, 5.0)
# Store for test report
if result.has("result"):
_test_results.append(result["result"])
return result
func _assert_screen_text(params: Dictionary) -> Dictionary:
## Assert that specific text is visible on screen.
## Uses find_ui_elements internally to check all visible UI text.
var text_result := require_string(params, "text")
if text_result[1] != null:
return text_result[1]
var expected_text: String = text_result[0]
var partial: bool = optional_bool(params, "partial", true)
var case_sensitive: bool = optional_bool(params, "case_sensitive", true)
# Use find_ui_elements to get all visible UI text
var ui_result := await _send_game_command("find_ui_elements", {})
if ui_result.has("error"):
return ui_result
var elements: Array = []
if ui_result.has("result") and ui_result["result"].has("elements"):
elements = ui_result["result"]["elements"]
var found := false
var matched_element: Dictionary = {}
var all_texts: Array[String] = []
for element: Dictionary in elements:
var element_text: String = str(element.get("text", ""))
if element_text.is_empty():
continue
all_texts.append(element_text)
var search_text := expected_text
var compare_text := element_text
if not case_sensitive:
search_text = search_text.to_lower()
compare_text = compare_text.to_lower()
if partial:
if compare_text.contains(search_text):
found = true
matched_element = element
break
else:
if compare_text == search_text:
found = true
matched_element = element
break
var assertion := {
"passed": found,
"expected_text": expected_text,
"partial": partial,
"case_sensitive": case_sensitive,
}
if found:
assertion["matched_element"] = {
"text": matched_element.get("text", ""),
"type": matched_element.get("type", ""),
"path": matched_element.get("path", ""),
}
else:
assertion["visible_texts"] = all_texts
# Store for test report
_test_results.append(assertion)
return success(assertion)
func _run_stress_test(params: Dictionary) -> Dictionary:
## Run rapid random inputs for N seconds and check for crashes.
## Returns frame count, timing, and any errors from game output.
var duration: float = float(params.get("duration", 5.0))
if duration <= 0 or duration > 60:
return error_invalid_params("Duration must be between 0 and 60 seconds")
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {
"suggestion": "Use play_scene first"
})
# Record initial error count from log
var initial_errors := _count_log_errors()
# Generate random input events
var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"]
# Add common game actions if specified
var custom_actions: Array = params.get("actions", [])
for action in custom_actions:
actions.append(str(action))
var events_sent: int = 0
var start_time := Time.get_ticks_msec()
var duration_ms := int(duration * 1000.0)
while Time.get_ticks_msec() - start_time < duration_ms:
if not ei.is_playing_scene():
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
return success({
"completed": false,
"crashed": true,
"elapsed_seconds": elapsed,
"events_sent": events_sent,
"error": "Game stopped during stress test",
})
# Send a batch of random inputs
var batch: Array = []
for j in 3:
var action_name: String = actions[randi() % actions.size()]
batch.append({
"type": "action",
"action": action_name,
"pressed": true,
"strength": 1.0,
})
batch.append({
"type": "action",
"action": action_name,
"pressed": false,
"strength": 0.0,
})
# Write input commands directly (same as input_commands)
var json := JSON.stringify({
"sequence_events": batch,
"frame_delay": 1,
})
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
events_sent += batch.size()
await get_tree().create_timer(0.1).timeout
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
var final_errors := _count_log_errors()
var new_errors := final_errors - initial_errors
# Check if game is still running
var still_running := ei.is_playing_scene()
return success({
"completed": true,
"crashed": not still_running,
"duration_seconds": elapsed,
"events_sent": events_sent,
"new_errors": new_errors,
"game_still_running": still_running,
})
func _get_test_report(params: Dictionary) -> Dictionary:
## Collect and format results from accumulated assertions into a test report.
## Returns pass count, fail count, and detailed results.
var clear: bool = optional_bool(params, "clear", true)
var pass_count: int = 0
var fail_count: int = 0
var details: Array[Dictionary] = []
for result: Dictionary in _test_results:
var passed: bool = result.get("passed", false)
if passed:
pass_count += 1
else:
fail_count += 1
details.append(result)
var report := {
"total": _test_results.size(),
"passed": pass_count,
"failed": fail_count,
"pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A",
"all_passed": fail_count == 0 and not _test_results.is_empty(),
"details": details,
}
if clear:
_test_results.clear()
return success(report)
# ── Step Executors (for run_test_scenario) ────────────────────────────────────
func _execute_input_step(step: Dictionary) -> Dictionary:
## Execute an input step: simulate action or key press.
var events: Array = []
if step.has("action"):
var pressed: bool = step.get("pressed", true) as bool
events.append({
"type": "action",
"action": str(step["action"]),
"pressed": pressed,
"strength": float(step.get("strength", 1.0)),
})
# Auto-release if pressed
if pressed and step.get("auto_release", true):
events.append({
"type": "action",
"action": str(step["action"]),
"pressed": false,
"strength": 0.0,
})
elif step.has("keycode"):
var pressed: bool = step.get("pressed", true) as bool
events.append({
"type": "key",
"keycode": str(step["keycode"]),
"pressed": pressed,
"shift": step.get("shift", false),
"ctrl": step.get("ctrl", false),
"alt": step.get("alt", false),
})
else:
return {"error": "Input step requires 'action' or 'keycode'"}
var json := JSON.stringify({
"sequence_events": events,
"frame_delay": int(step.get("frame_delay", 1)),
})
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
if file == null:
return {"error": "Failed to write input commands"}
file.store_string(json)
file.close()
return {"sent": true, "event_count": events.size()}
func _execute_wait_step(step: Dictionary) -> Dictionary:
## Execute a wait step: wait for seconds or wait for a node to appear.
if step.has("node_path"):
var timeout: float = float(step.get("timeout", 5.0))
var result := await _send_game_command("wait_for_node", {
"node_path": str(step["node_path"]),
"timeout": timeout,
"poll_frames": int(step.get("poll_frames", 5)),
}, timeout + 2.0)
if result.has("error"):
return {"error": "Wait for node failed: %s" % str(result["error"])}
return {"waited_for": str(step["node_path"]), "found": true}
else:
var seconds: float = float(step.get("seconds", 1.0))
await get_tree().create_timer(seconds).timeout
return {"waited_seconds": seconds}
func _execute_assert_step(step: Dictionary) -> Dictionary:
## Execute an assertion step within a scenario.
if step.has("text"):
# Screen text assertion
var ui_result := await _send_game_command("find_ui_elements", {})
if ui_result.has("error"):
return {"passed": false, "error": "Could not get UI elements"}
var elements: Array = []
if ui_result.has("result") and ui_result["result"].has("elements"):
elements = ui_result["result"]["elements"]
var expected_text: String = str(step["text"])
var partial: bool = step.get("partial", true) as bool
for element: Dictionary in elements:
var element_text: String = str(element.get("text", ""))
if partial and element_text.contains(expected_text):
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
elif not partial and element_text == expected_text:
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
return {"passed": false, "type": "screen_text", "expected": expected_text, "error": "Text not found on screen"}
elif step.has("node_path") and step.has("property"):
# Node state assertion
var result := await _send_game_command("assert_node_state", {
"node_path": str(step["node_path"]),
"property": str(step["property"]),
"expected": step.get("expected", null),
"operator": str(step.get("operator", "eq")),
}, 5.0)
if result.has("result"):
return result["result"]
elif result.has("error"):
return {"passed": false, "error": str(result["error"])}
return {"passed": false, "error": "Unknown assertion error"}
else:
return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"}
# ── IPC Helper ────────────────────────────────────────────────────────────────
func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_game_request"
var response_path := user_dir + "/mcp_game_response"
# Clean stale response
if FileAccess.file_exists(response_path):
DirAccess.remove_absolute(response_path)
# Write request
var request_data := JSON.stringify({"command": command, "params": params})
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create game request file")
req.store_string(request_data)
req.close()
# Poll for response
var attempts := int(timeout_sec / 0.1)
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
# Check if game is still running
if not ei.is_playing_scene():
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game stopped during command execution")
attempts -= 1
if not FileAccess.file_exists(response_path):
# Try to auto-resume the debugger
if ei.is_playing_scene():
_try_debugger_continue()
for _retry in 20:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(response_path):
break
if not FileAccess.file_exists(response_path):
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
})
# Read response
var file := FileAccess.open(response_path, FileAccess.READ)
if file == null:
return error_internal("Could not read game response file")
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(response_path)
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
return error_internal("Invalid response JSON from game")
if parsed.has("error"):
return error(-32000, str(parsed["error"]))
return success(parsed)
## Press the debugger "Continue" button to resume a paused game process.
func _try_debugger_continue() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var queue: Array[Node] = [base]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
var inner: Array[Node] = [node]
while not inner.is_empty():
var n := inner.pop_front()
if n is Button and n.tooltip_text == "Continue":
n.emit_signal("pressed")
push_warning("[MCP] Auto-resumed debugger after runtime error")
return
for c in n.get_children():
inner.append(c)
return
for child in node.get_children():
queue.append(child)
# ── Utility ───────────────────────────────────────────────────────────────────
func _count_log_errors() -> int:
var count: int = 0
var log_path := "user://logs/godot.log"
if FileAccess.file_exists(log_path):
var file := FileAccess.open(log_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
for line: String in lines:
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
count += 1
return count

View File

@@ -0,0 +1,424 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"create_theme": _create_theme,
"set_theme_color": _set_theme_color,
"set_theme_constant": _set_theme_constant,
"set_theme_font_size": _set_theme_font_size,
"set_theme_stylebox": _set_theme_stylebox,
"setup_control": _setup_control,
"get_theme_info": _get_theme_info,
}
func _create_theme(params: Dictionary) -> Dictionary:
var result := require_string(params, "path")
if result[1] != null:
return result[1]
var path: String = result[0]
var theme := Theme.new()
# Optionally set default font size
var font_size: int = optional_int(params, "default_font_size", 0)
if font_size > 0:
theme.default_font_size = font_size
var scene_guard := guard_offline_scene_save(path)
if scene_guard != null:
return scene_guard
var err := ResourceSaver.save(theme, path)
if err != OK:
return error_internal("Failed to save theme: %s" % error_string(err))
EditorInterface.get_resource_filesystem().scan()
return success({"path": path, "created": true})
func _set_theme_color(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var color_name: String = result2[0]
var result3 := require_string(params, "color")
if result3[1] != null:
return result3[1]
var color_str: String = result3[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var color := Color(color_str)
var theme_type: String = optional_string(params, "theme_type", "")
if theme_type.is_empty():
theme_type = control.get_class()
var had_old := control.has_theme_color_override(color_name)
var old_value: Variant = control.get("theme_override_colors/" + color_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme color override")
undo_redo.add_do_method(control, "add_theme_color_override", color_name, color)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "color", color_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": color_name, "color": color_str})
func _set_theme_constant(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var const_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var value: int = int(params.get("value", 0))
var had_old := control.has_theme_constant_override(const_name)
var old_value: Variant = control.get("theme_override_constants/" + const_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme constant override")
undo_redo.add_do_method(control, "add_theme_constant_override", const_name, value)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", const_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": const_name, "value": value})
func _set_theme_font_size(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var font_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var size: int = int(params.get("size", 16))
var had_old := control.has_theme_font_size_override(font_name)
var old_value: Variant = control.get("theme_override_font_sizes/" + font_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme font size override")
undo_redo.add_do_method(control, "add_theme_font_size_override", font_name, size)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "font_size", font_name, had_old, old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": font_name, "size": size})
func _set_theme_stylebox(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var result2 := require_string(params, "name")
if result2[1] != null:
return result2[1]
var style_name: String = result2[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var stylebox := StyleBoxFlat.new()
var bg_color: String = optional_string(params, "bg_color", "")
if not bg_color.is_empty():
stylebox.bg_color = Color(bg_color)
var border_color: String = optional_string(params, "border_color", "")
if not border_color.is_empty():
stylebox.border_color = Color(border_color)
var border_width: int = optional_int(params, "border_width", 0)
if border_width > 0:
stylebox.border_width_left = border_width
stylebox.border_width_top = border_width
stylebox.border_width_right = border_width
stylebox.border_width_bottom = border_width
var corner_radius: int = optional_int(params, "corner_radius", 0)
if corner_radius > 0:
stylebox.corner_radius_top_left = corner_radius
stylebox.corner_radius_top_right = corner_radius
stylebox.corner_radius_bottom_left = corner_radius
stylebox.corner_radius_bottom_right = corner_radius
var padding: int = optional_int(params, "padding", 0)
if padding > 0:
stylebox.content_margin_left = padding
stylebox.content_margin_top = padding
stylebox.content_margin_right = padding
stylebox.content_margin_bottom = padding
var had_old := control.has_theme_stylebox_override(style_name)
var old_value: Variant = control.get("theme_override_styles/" + style_name) if had_old else null
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set theme stylebox override")
undo_redo.add_do_method(control, "add_theme_stylebox_override", style_name, stylebox)
undo_redo.add_do_reference(stylebox)
undo_redo.add_undo_method(self, "_restore_theme_override", control, "stylebox", style_name, had_old, old_value)
if old_value is Resource:
undo_redo.add_undo_reference(old_value)
undo_redo.commit_action()
return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"})
func _setup_control(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var applied: Array = []
var old_state := _capture_control_setup_state(control)
var target: Control = control.duplicate() as Control
# Anchor preset
var anchor_preset: String = optional_string(params, "anchor_preset", "")
if not anchor_preset.is_empty():
var preset_map := {
"top_left": Control.PRESET_TOP_LEFT,
"top_right": Control.PRESET_TOP_RIGHT,
"bottom_left": Control.PRESET_BOTTOM_LEFT,
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
"center_left": Control.PRESET_CENTER_LEFT,
"center_top": Control.PRESET_CENTER_TOP,
"center_right": Control.PRESET_CENTER_RIGHT,
"center_bottom": Control.PRESET_CENTER_BOTTOM,
"center": Control.PRESET_CENTER,
"left_wide": Control.PRESET_LEFT_WIDE,
"top_wide": Control.PRESET_TOP_WIDE,
"right_wide": Control.PRESET_RIGHT_WIDE,
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
"full_rect": Control.PRESET_FULL_RECT,
}
if preset_map.has(anchor_preset):
target.set_anchors_and_offsets_preset(preset_map[anchor_preset])
applied.append("anchor_preset=%s" % anchor_preset)
# Min size
var min_size_str: String = optional_string(params, "min_size", "")
if not min_size_str.is_empty():
var expr := Expression.new()
if expr.parse(min_size_str) == OK:
var val = expr.execute()
if val is Vector2:
target.custom_minimum_size = val
applied.append("min_size=%s" % min_size_str)
# Size flags horizontal
var sf_h: String = optional_string(params, "size_flags_h", "")
if not sf_h.is_empty():
var flags_map := {
"fill": Control.SIZE_FILL,
"expand": Control.SIZE_EXPAND,
"fill_expand": Control.SIZE_EXPAND_FILL,
"shrink_center": Control.SIZE_SHRINK_CENTER,
"shrink_end": Control.SIZE_SHRINK_END,
}
if flags_map.has(sf_h):
target.size_flags_horizontal = flags_map[sf_h]
applied.append("size_flags_h=%s" % sf_h)
# Size flags vertical
var sf_v: String = optional_string(params, "size_flags_v", "")
if not sf_v.is_empty():
var flags_map := {
"fill": Control.SIZE_FILL,
"expand": Control.SIZE_EXPAND,
"fill_expand": Control.SIZE_EXPAND_FILL,
"shrink_center": Control.SIZE_SHRINK_CENTER,
"shrink_end": Control.SIZE_SHRINK_END,
}
if flags_map.has(sf_v):
target.size_flags_vertical = flags_map[sf_v]
applied.append("size_flags_v=%s" % sf_v)
# Margins (for MarginContainer)
if params.has("margins") and params["margins"] is Dictionary:
var margins: Dictionary = params["margins"]
if target is MarginContainer:
if margins.has("left"):
target.add_theme_constant_override("margin_left", int(margins["left"]))
if margins.has("top"):
target.add_theme_constant_override("margin_top", int(margins["top"]))
if margins.has("right"):
target.add_theme_constant_override("margin_right", int(margins["right"]))
if margins.has("bottom"):
target.add_theme_constant_override("margin_bottom", int(margins["bottom"]))
applied.append("margins=%s" % str(margins))
# Separation (for VBox/HBoxContainer)
if params.has("separation"):
var sep: int = int(params["separation"])
if target is BoxContainer:
target.add_theme_constant_override("separation", sep)
applied.append("separation=%d" % sep)
# Grow direction horizontal
var grow_h: String = optional_string(params, "grow_h", "")
if not grow_h.is_empty():
var grow_map := {
"begin": Control.GROW_DIRECTION_BEGIN,
"end": Control.GROW_DIRECTION_END,
"both": Control.GROW_DIRECTION_BOTH,
}
if grow_map.has(grow_h):
target.grow_horizontal = grow_map[grow_h]
applied.append("grow_h=%s" % grow_h)
# Grow direction vertical
var grow_v: String = optional_string(params, "grow_v", "")
if not grow_v.is_empty():
var grow_map := {
"begin": Control.GROW_DIRECTION_BEGIN,
"end": Control.GROW_DIRECTION_END,
"both": Control.GROW_DIRECTION_BOTH,
}
if grow_map.has(grow_v):
target.grow_vertical = grow_map[grow_v]
applied.append("grow_v=%s" % grow_v)
if not applied.is_empty():
var new_state := _capture_control_setup_state(target)
_register_control_setup_undo(control, old_state, new_state)
target.free()
return success({"node_path": node_path, "applied": applied, "count": applied.size()})
func _restore_theme_override(control: Control, kind: String, override_name: String, had_old: bool, old_value: Variant) -> void:
match kind:
"color":
if had_old:
control.add_theme_color_override(override_name, old_value)
else:
control.remove_theme_color_override(override_name)
"constant":
if had_old:
control.add_theme_constant_override(override_name, old_value)
else:
control.remove_theme_constant_override(override_name)
"font_size":
if had_old:
control.add_theme_font_size_override(override_name, old_value)
else:
control.remove_theme_font_size_override(override_name)
"stylebox":
if had_old:
control.add_theme_stylebox_override(override_name, old_value)
else:
control.remove_theme_stylebox_override(override_name)
func _capture_control_setup_state(control: Control) -> Dictionary:
var state := {"properties": {}, "theme_constants": {}}
for property: String in [
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
"offset_left", "offset_top", "offset_right", "offset_bottom",
"custom_minimum_size", "size_flags_horizontal", "size_flags_vertical",
"grow_horizontal", "grow_vertical",
]:
state["properties"][property] = control.get(property)
for constant_name: String in ["margin_left", "margin_top", "margin_right", "margin_bottom", "separation"]:
var had_override := control.has_theme_constant_override(constant_name)
state["theme_constants"][constant_name] = {
"had": had_override,
"value": control.get("theme_override_constants/" + constant_name) if had_override else null,
}
return state
func _register_control_setup_undo(control: Control, old_state: Dictionary, new_state: Dictionary) -> void:
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Setup Control")
for property: String in new_state["properties"]:
undo_redo.add_do_property(control, property, new_state["properties"][property])
undo_redo.add_undo_property(control, property, old_state["properties"][property])
for constant_name: String in new_state["theme_constants"]:
var new_constant: Dictionary = new_state["theme_constants"][constant_name]
var old_constant: Dictionary = old_state["theme_constants"][constant_name]
undo_redo.add_do_method(self, "_restore_theme_override", control, "constant", constant_name, new_constant["had"], new_constant["value"])
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", constant_name, old_constant["had"], old_constant["value"])
undo_redo.commit_action()
func _get_theme_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var node := find_node_by_path(node_path)
if node == null or not (node is Control):
return error_not_found("Control node at '%s'" % node_path)
var control: Control = node
var info := {"node_path": node_path, "class": control.get_class()}
# Check if node has a theme
var theme := control.theme
if theme:
info["theme_path"] = theme.resource_path
info["type_list"] = Array(theme.get_type_list())
# List overrides
var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}}
for prop in control.get_property_list():
var pname: String = prop["name"]
if pname.begins_with("theme_override_colors/"):
var key := pname.substr(22)
overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html()
elif pname.begins_with("theme_override_constants/"):
var key := pname.substr(25)
overrides["constants"][key] = control.get(pname)
elif pname.begins_with("theme_override_font_sizes/"):
var key := pname.substr(26)
overrides["font_sizes"][key] = control.get(pname)
elif pname.begins_with("theme_override_styles/"):
var key := pname.substr(22)
var style = control.get(pname)
overrides["styleboxes"][key] = style.get_class() if style else null
info["overrides"] = overrides
return success(info)

View File

@@ -0,0 +1,217 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"tilemap_set_cell": _tilemap_set_cell,
"tilemap_fill_rect": _tilemap_fill_rect,
"tilemap_get_cell": _tilemap_get_cell,
"tilemap_clear": _tilemap_clear,
"tilemap_get_info": _tilemap_get_info,
"tilemap_get_used_cells": _tilemap_get_used_cells,
}
func _find_tilemap(node_path: String) -> TileMapLayer:
var node := find_node_by_path(node_path)
if node is TileMapLayer:
return node as TileMapLayer
return null
func _tilemap_set_cell(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x: int = int(params.get("x", 0))
var y: int = int(params.get("y", 0))
var source_id: int = int(params.get("source_id", 0))
var atlas_x: int = int(params.get("atlas_x", 0))
var atlas_y: int = int(params.get("atlas_y", 0))
var alternative: int = int(params.get("alternative", 0))
var coords := Vector2i(x, y)
var old_cells := [_capture_cell(tilemap, coords)]
var new_cells := [_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative)]
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Set TileMap cell")
_add_do_set_cells(undo_redo, tilemap, new_cells)
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"x": x, "y": y, "source_id": source_id, "atlas_coords": [atlas_x, atlas_y]})
func _tilemap_fill_rect(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x1: int = int(params.get("x1", 0))
var y1: int = int(params.get("y1", 0))
var x2: int = int(params.get("x2", 0))
var y2: int = int(params.get("y2", 0))
var source_id: int = int(params.get("source_id", 0))
var atlas_x: int = int(params.get("atlas_x", 0))
var atlas_y: int = int(params.get("atlas_y", 0))
var alternative: int = int(params.get("alternative", 0))
var count := 0
var old_cells: Array = []
var new_cells: Array = []
for cx in range(mini(x1, x2), maxi(x1, x2) + 1):
for cy in range(mini(y1, y2), maxi(y1, y2) + 1):
var coords := Vector2i(cx, cy)
old_cells.append(_capture_cell(tilemap, coords))
new_cells.append(_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative))
count += 1
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Fill TileMap rect")
_add_do_set_cells(undo_redo, tilemap, new_cells)
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"filled": count, "rect": [x1, y1, x2, y2]})
func _tilemap_get_cell(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var x: int = int(params.get("x", 0))
var y: int = int(params.get("y", 0))
var coords := Vector2i(x, y)
var source_id := tilemap.get_cell_source_id(coords)
var atlas_coords := tilemap.get_cell_atlas_coords(coords)
var alternative := tilemap.get_cell_alternative_tile(coords)
return success({
"x": x, "y": y,
"source_id": source_id,
"atlas_coords": [atlas_coords.x, atlas_coords.y],
"alternative": alternative,
"empty": source_id == -1,
})
func _tilemap_clear(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var old_cells: Array = []
for coords: Vector2i in tilemap.get_used_cells():
old_cells.append(_capture_cell(tilemap, coords))
var undo_redo := get_undo_redo()
undo_redo.create_action("MCP: Clear TileMap")
undo_redo.add_do_method(tilemap, "clear")
_add_undo_set_cells(undo_redo, tilemap, old_cells)
undo_redo.commit_action()
return success({"cleared": true})
func _make_cell(coords: Vector2i, source_id: int, atlas_coords: Vector2i, alternative: int) -> Dictionary:
return {
"coords": coords,
"source_id": source_id,
"atlas_coords": atlas_coords,
"alternative": alternative,
}
func _capture_cell(tilemap: TileMapLayer, coords: Vector2i) -> Dictionary:
return _make_cell(
coords,
tilemap.get_cell_source_id(coords),
tilemap.get_cell_atlas_coords(coords),
tilemap.get_cell_alternative_tile(coords)
)
func _add_do_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
for cell: Dictionary in cells:
undo_redo.add_do_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
func _add_undo_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
for cell: Dictionary in cells:
undo_redo.add_undo_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
func _tilemap_get_info(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var tile_set := tilemap.tile_set
var sources: Array = []
if tile_set:
for i in tile_set.get_source_count():
var source_id := tile_set.get_source_id(i)
var source := tile_set.get_source(source_id)
var info := {"id": source_id, "type": source.get_class()}
if source is TileSetAtlasSource:
var atlas: TileSetAtlasSource = source
info["texture"] = atlas.texture.resource_path if atlas.texture else ""
info["tile_count"] = atlas.get_tiles_count()
sources.append(info)
return success({
"node_path": node_path,
"used_cells": tilemap.get_used_cells().size(),
"tile_set_sources": sources,
"tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0],
})
func _tilemap_get_used_cells(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var tilemap := _find_tilemap(node_path)
if tilemap == null:
return error_not_found("TileMapLayer at '%s'" % node_path)
var max_count: int = optional_int(params, "max_count", 500)
var cells: Array = []
var used := tilemap.get_used_cells()
for i in mini(used.size(), max_count):
var pos: Vector2i = used[i]
cells.append({"x": pos.x, "y": pos.y, "source_id": tilemap.get_cell_source_id(pos)})
return success({"cells": cells, "total": used.size(), "returned": cells.size()})

View File

@@ -0,0 +1,198 @@
## Autoload injected by Godot MCP Pro plugin at runtime.
## Monitors for input commands from the editor and dispatches them as Input events.
extends Node
const COMMANDS_PATH := "user://mcp_input_commands"
var _sequence_queue: Array = [] # Array of event dicts
var _sequence_frame_delay: int = 0
var _sequence_frames_waited: int = 0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(_delta: float) -> void:
# Process queued sequence events
if not _sequence_queue.is_empty():
_process_sequence_tick()
# Check for new commands from file
if FileAccess.file_exists(COMMANDS_PATH):
_process_commands()
func _process_commands() -> void:
var file := FileAccess.open(COMMANDS_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(COMMANDS_PATH)
var parsed = JSON.parse_string(text)
if parsed == null:
push_warning("[MCP Input] Failed to parse input commands JSON")
return
# Check if this is a sequence command (dict with "events" and "frame_delay")
if parsed is Dictionary and parsed.has("sequence_events"):
_start_sequence(parsed)
return
# Otherwise treat as immediate event(s)
var events: Array = parsed if parsed is Array else [parsed]
for event_data: Dictionary in events:
var event := _create_event(event_data)
if event != null:
_dispatch_event(event, event_data)
func _start_sequence(data: Dictionary) -> void:
_sequence_queue = data.get("sequence_events", []).duplicate()
_sequence_frame_delay = data.get("frame_delay", 1)
_sequence_frames_waited = 0
# Dispatch first event immediately
if not _sequence_queue.is_empty():
_dispatch_next_sequence_event()
func _process_sequence_tick() -> void:
_sequence_frames_waited += 1
if _sequence_frames_waited >= _sequence_frame_delay:
_sequence_frames_waited = 0
_dispatch_next_sequence_event()
func _dispatch_next_sequence_event() -> void:
if _sequence_queue.is_empty():
return
var event_data: Dictionary = _sequence_queue.pop_front()
var event := _create_event(event_data)
if event != null:
_dispatch_event(event, event_data)
## Dispatch an input event using the appropriate method.
## Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass
## GUI consumption and reach _unhandled_input — needed for camera-pan use
## cases where UI Controls would otherwise swallow drag events. But for UI
## drag-and-drop *testing* we want events to reach the GUI dispatcher so
## hit-testing and _get_drag_data / _drop_data fire. So: respect an explicit
## "unhandled": false in the event payload — only auto-promote when the
## caller did NOT pass an "unhandled" key. Default behavior preserved.
func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void:
var force_unhandled: bool
if event_data.has("unhandled"):
force_unhandled = bool(event_data.get("unhandled"))
else:
force_unhandled = event is InputEventMouseMotion and event.button_mask != 0
if force_unhandled:
var vp := get_viewport()
if vp:
vp.push_input(event, true)
else:
Input.parse_input_event(event)
else:
Input.parse_input_event(event)
func _create_event(data: Dictionary) -> InputEvent:
var type: String = data.get("type", "")
match type:
"key":
return _create_key_event(data)
"mouse_button":
return _create_mouse_button_event(data)
"mouse_motion":
return _create_mouse_motion_event(data)
"action":
return _create_action_event(data)
_:
push_warning("[MCP Input] Unknown event type: %s" % type)
return null
## Convert viewport coordinates to window coordinates for Input.parse_input_event().
## Godot applies viewport.get_final_transform() to mouse events internally,
## so we must pass window-space coordinates (pre-transform).
func _viewport_to_window(viewport_pos: Vector2) -> Vector2:
var vp := get_viewport()
if vp == null:
return viewport_pos
var xform := vp.get_final_transform()
return xform * viewport_pos
func _create_key_event(data: Dictionary) -> InputEventKey:
var event := InputEventKey.new()
var keycode_str: String = data.get("keycode", "")
if keycode_str.begins_with("KEY_"):
var constant_value = ClassDB.class_get_integer_constant("@GlobalScope", keycode_str)
if constant_value != 0:
event.keycode = constant_value
else:
event.keycode = OS.find_keycode_from_string(keycode_str.substr(4))
else:
event.keycode = OS.find_keycode_from_string(keycode_str)
event.pressed = data.get("pressed", true)
event.shift_pressed = data.get("shift", false)
event.ctrl_pressed = data.get("ctrl", false)
event.alt_pressed = data.get("alt", false)
return event
func _extract_position(data: Dictionary) -> Vector2:
# Support nested {"position": {"x": ..., "y": ...}} or flat {"x": ..., "y": ...}
var pos = data.get("position", null)
if pos is Dictionary:
return Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
return Vector2(data.get("x", 0.0), data.get("y", 0.0))
func _create_mouse_button_event(data: Dictionary) -> InputEventMouseButton:
var event := InputEventMouseButton.new()
event.button_index = data.get("button", MOUSE_BUTTON_LEFT)
event.pressed = data.get("pressed", true)
event.double_click = data.get("double_click", false)
var window_pos := _viewport_to_window(_extract_position(data))
event.position = window_pos
event.global_position = window_pos
return event
func _create_mouse_motion_event(data: Dictionary) -> InputEventMouseMotion:
var event := InputEventMouseMotion.new()
var window_pos := _viewport_to_window(_extract_position(data))
event.position = window_pos
event.global_position = window_pos
# Support nested {"relative": {"x": ..., "y": ...}} or flat {"relative_x": ..., "relative_y": ...}
var rel_x: float = 0.0
var rel_y: float = 0.0
var rel = data.get("relative", null)
if rel is Dictionary:
rel_x = float(rel.get("x", 0.0))
rel_y = float(rel.get("y", 0.0))
else:
rel_x = float(data.get("relative_x", 0.0))
rel_y = float(data.get("relative_y", 0.0))
# Scale relative movement by the same transform (scale only, no offset)
var vp := get_viewport()
if vp:
var scale := vp.get_final_transform().get_scale()
event.relative = Vector2(rel_x, rel_y) * scale
else:
event.relative = Vector2(rel_x, rel_y)
# Set button_mask so drag detection works (e.g. camera pan checks button_mask)
var button_mask: int = int(data.get("button_mask", 0))
event.button_mask = button_mask
return event
func _create_action_event(data: Dictionary) -> InputEventAction:
var event := InputEventAction.new()
event.action = data.get("action", "")
event.pressed = data.get("pressed", true)
event.strength = data.get("strength", 1.0)
return event

View File

@@ -0,0 +1,34 @@
## Autoload injected by Godot MCP Pro plugin at runtime.
## Monitors for screenshot requests from the editor and captures the game viewport.
extends Node
const REQUEST_PATH := "user://mcp_screenshot_request"
const SCREENSHOT_PATH := "user://mcp_screenshot.png"
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(_delta: float) -> void:
if FileAccess.file_exists(REQUEST_PATH):
_take_screenshot()
func _take_screenshot() -> void:
# Delete request file immediately to avoid re-triggering
DirAccess.remove_absolute(REQUEST_PATH)
# Wait one frame so the viewport has a fully rendered image
# process_always=true (default) so the timer ticks even when tree is paused
await get_tree().create_timer(0.05).timeout
var viewport := get_viewport()
if viewport == null:
return
var image := viewport.get_texture().get_image()
if image == null:
return
image.save_png(SCREENSHOT_PATH)

View File

@@ -0,0 +1,7 @@
[plugin]
name="Godot MCP Pro"
description="Premium MCP server for AI-powered Godot development. Connects via WebSocket to expose 172 editor tools."
author="godot-mcp-pro"
version="1.14.1"
script="plugin.gd"

View File

@@ -0,0 +1,183 @@
@tool
extends EditorPlugin
const _MCP_AUTOLOADS: Array[Array] = [
["autoload/MCPScreenshot", "res://addons/godot_mcp/mcp_screenshot_service.gd"],
["autoload/MCPInputService", "res://addons/godot_mcp/mcp_input_service.gd"],
["autoload/MCPGameInspector", "res://addons/godot_mcp/mcp_game_inspector_service.gd"],
]
const _MCP_TEMP_FILES: Array[String] = [
"mcp_game_request",
"mcp_game_response",
"mcp_input_commands",
"mcp_screenshot_request",
]
var websocket_server: Node
var command_router: Node
var status_panel: Control
var auto_dismiss_dialogs: bool = false
# Track which autoloads THIS session injected (vs project-owned)
var _session_injected_autoloads: Array[String] = []
func _enter_tree() -> void:
# Create command router
command_router = preload("res://addons/godot_mcp/command_router.gd").new()
command_router.name = "MCPCommandRouter"
command_router.editor_plugin = self
add_child(command_router)
# Create WebSocket server
websocket_server = preload("res://addons/godot_mcp/websocket_server.gd").new()
websocket_server.name = "MCPWebSocketServer"
websocket_server.command_router = command_router
add_child(websocket_server)
# Create status panel
var panel_scene: PackedScene = preload("res://addons/godot_mcp/ui/status_panel.tscn")
status_panel = panel_scene.instantiate()
add_control_to_bottom_panel(status_panel, "MCP Pro")
status_panel.call_deferred("setup", websocket_server, command_router)
# Inject MCP autoloads into project settings
_inject_autoloads()
websocket_server.start_server()
var cfg := ConfigFile.new()
var ver := "unknown"
if cfg.load("res://addons/godot_mcp/plugin.cfg") == OK:
ver = cfg.get_value("plugin", "version", "unknown")
print("[MCP] Godot MCP Pro v%s started (ports 6505-6514)" % ver)
func _exit_tree() -> void:
# Remove MCP autoloads and clean up temp files
_remove_autoloads()
_cleanup_temp_files()
if websocket_server:
websocket_server.stop_server()
if status_panel:
remove_control_from_bottom_panel(status_panel)
status_panel.queue_free()
if command_router:
command_router.queue_free()
if websocket_server:
websocket_server.queue_free()
print("[MCP] Godot MCP Pro stopped")
func _inject_autoloads() -> void:
_session_injected_autoloads.clear()
var changed := false
for entry: Array in _MCP_AUTOLOADS:
var key: String = entry[0]
var script: String = entry[1]
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, "*" + script)
_session_injected_autoloads.append(key)
changed = true
if changed:
ProjectSettings.save()
func _remove_autoloads() -> void:
# Only remove autoloads that THIS session injected.
# Pre-existing project-owned autoloads are preserved.
var changed := false
for key: String in _session_injected_autoloads:
if ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, null)
changed = true
_session_injected_autoloads.clear()
if changed:
ProjectSettings.save()
var _dialog_check_timer: float = 0.0
const _DIALOG_CHECK_INTERVAL: float = 0.5 # Check every 0.5 seconds
func _process(delta: float) -> void:
# Check if game inspector requested debugger continue
var flag_path := OS.get_user_data_dir() + "/mcp_debugger_continue"
if FileAccess.file_exists(flag_path):
DirAccess.remove_absolute(flag_path)
_try_debugger_continue()
# Periodically check for blocking editor dialogs (only when enabled by AI)
if auto_dismiss_dialogs:
_dialog_check_timer += delta
if _dialog_check_timer >= _DIALOG_CHECK_INTERVAL:
_dialog_check_timer = 0.0
_auto_dismiss_dialogs()
func _try_debugger_continue() -> void:
# Last resort: find and press the debugger Continue button to unstick the game
var base: Node = EditorInterface.get_base_control()
var continue_btn := _find_debugger_continue_button(base)
if continue_btn and continue_btn.visible and not continue_btn.disabled:
continue_btn.emit_signal("pressed")
push_warning("[MCP] Auto-pressed debugger Continue button")
else:
push_warning("[MCP] Could not find debugger Continue button")
func _find_debugger_continue_button(node: Node) -> Button:
# Search for the Continue button in ScriptEditorDebugger
if node is Button:
var btn: Button = node
if btn.tooltip_text.contains("Continue") or btn.text == "Continue":
return btn
for child in node.get_children():
var found: Button = _find_debugger_continue_button(child)
if found:
return found
return null
func _auto_dismiss_dialogs() -> void:
var base: Node = EditorInterface.get_base_control()
if not base:
return
_find_and_dismiss_dialogs(base)
func _find_and_dismiss_dialogs(node: Node) -> void:
if node is AcceptDialog and node.visible:
var dialog: AcceptDialog = node
# Never dismiss file dialogs or non-modal popups
if dialog is FileDialog:
return
if not dialog.exclusive:
return
# Get dialog title/text for logging
var title := dialog.title
var text := dialog.dialog_text
# Accept the dialog (presses OK / confirms)
dialog.get_ok_button().emit_signal("pressed")
push_warning("[MCP] Auto-dismissed editor dialog: '%s'%s" % [title, text])
return # One dialog per check cycle to avoid side effects
for child in node.get_children():
# Only search visible Windows to keep the scan lightweight
if child is Window and not child.visible:
continue
_find_and_dismiss_dialogs(child)
func _cleanup_temp_files() -> void:
var user_dir := OS.get_user_data_dir()
for filename: String in _MCP_TEMP_FILES:
var path := user_dir + "/" + filename
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
# Also clean up screenshot image
var screenshot_path := user_dir + "/mcp_screenshot.png"
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | Español | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills para Asistentes de IA
> Copia este archivo a `.claude/skills.md` en la raíz de tu proyecto Godot para darle a Claude Code el contexto completo sobre cómo usar Godot MCP Pro de forma efectiva.
## ¿Qué es Godot MCP Pro?
Tienes acceso a 169 herramientas MCP que se conectan directamente al editor de Godot 4. Puedes crear escenas, escribir scripts, simular entrada del jugador, inspeccionar juegos en ejecución y más — todo sin que el usuario salga de esta conversación. Cada cambio pasa por el sistema UndoRedo de Godot, así que el usuario siempre puede hacer Ctrl+Z.
## Flujos de Trabajo Esenciales
### 1. Explorar un Proyecto
Siempre empieza entendiendo el proyecto antes de hacer cambios:
```
get_project_info → nombre del proyecto, versión de Godot, renderizador, tamaño del viewport
get_filesystem_tree → estructura de directorios (usa filter: "*.tscn" o "*.gd")
get_scene_tree → jerarquía de nodos de la escena abierta actualmente
read_script → leer cualquier archivo GDScript
get_project_settings → revisar la configuración del proyecto
```
### 2. Construir una Escena 2D
```
create_scene → crear archivo .tscn con tipo de nodo raíz
add_node → agregar nodos hijos con propiedades
create_script → escribir GDScript para lógica del juego
attach_script → adjuntar script a un nodo
update_property → establecer position, scale, modulate, etc.
save_scene → guardar en disco
```
**Ejemplo — creando un jugador:**
1. `create_scene` con root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` tipo `Sprite2D` con propiedad texture
3. `add_node` tipo `CollisionShape2D`
4. `add_resource` para asignar una shape (ej: `RectangleShape2D`) al CollisionShape2D
5. `create_script` con lógica de movimiento
6. `attach_script` al nodo raíz
7. `save_scene`
### 3. Construir una Escena 3D
```
create_scene → root_type: Node3D
add_mesh_instance → agregar primitivas (box, sphere, cylinder, plane) o importar .glb/.gltf
setup_lighting → agregar DirectionalLight3D, OmniLight3D o SpotLight3D
setup_environment → cielo, luz ambiental, niebla, tonemap
setup_camera_3d → cámara con SpringArm3D opcional para tercera persona
set_material_3d → materiales PBR (albedo, metallic, roughness, emission)
setup_collision → agregar shapes de colisión a cuerpos físicos
setup_physics_body → configurar masa, fricción, gravedad
```
### 4. Escribir y Editar Scripts
```
create_script → crear nuevo archivo .gd (proporciona el contenido completo)
edit_script → modificar scripts existentes
- Usa `replacements: [{search: "old code", replace: "new code"}]` para ediciones específicas
- Usa `content` para reemplazo completo del archivo
- Usa `insert_at_line` + `text` para insertar código
validate_script → verificar errores de sintaxis sin ejecutar
read_script → leer contenido actual antes de editar
```
### 5. Probar y Depurar
```
play_scene → lanzar el juego (mode: "current", "main" o ruta de archivo)
get_game_screenshot → ver cómo luce el juego en este momento
capture_frames → capturar múltiples frames para observar movimiento/animación
get_game_scene_tree → inspeccionar el árbol de escena en tiempo de ejecución
get_game_node_properties → leer valores en runtime (position, health, state, etc.)
set_game_node_property → modificar valores en el juego en ejecución
simulate_key → presionar teclas (WASD, SPACE, etc.) con duración
simulate_mouse_click → hacer clic en coordenadas del viewport
simulate_action → disparar acciones del InputMap (move_left, jump, etc.)
get_editor_errors → revisar errores de ejecución
stop_scene → detener el juego
```
**Ciclo de playtesting:**
1. `play_scene` → iniciar el juego
2. `get_game_screenshot` → ver estado actual
3. `simulate_key` / `simulate_action` → interactuar con el juego
4. `capture_frames` → observar comportamiento a lo largo del tiempo
5. `get_game_node_properties` → verificar valores específicos
6. `stop_scene` → detener cuando termines
7. Corregir problemas en scripts → repetir
### 6. Animaciones
```
# Asegúrate de que exista un nodo AnimationPlayer en la escena
create_animation → nueva animación con duración y modo de loop
add_animation_track → agregar tracks de property/transform/method
set_animation_keyframe → insertar keyframes en tiempos específicos
get_animation_info → inspeccionar animaciones existentes
```
**Ejemplo — sprite rebotando:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop lineal)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → cambiar font_color, etc.
set_theme_font_size → ajustar tamaño de texto
set_theme_stylebox → fondos, bordes, esquinas redondeadas
connect_signal → conectar pressed del button, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → revisar fuentes del tile set y disposición del atlas
tilemap_set_cell → colocar tiles individuales
tilemap_fill_rect → rellenar regiones rectangulares
tilemap_get_used_cells → ver qué ya está colocado
tilemap_clear → limpiar todas las celdas
```
### 9. Audio
```
add_audio_bus → crear buses de audio (SFX, Music, UI)
set_audio_bus → ajustar volumen, solo, mute
add_audio_bus_effect → agregar reverb, delay, compressor, etc.
add_audio_player → agregar nodos AudioStreamPlayer(2D/3D)
```
### 10. Configuración del Proyecto
```
set_project_setting → cambiar tamaño del viewport, configuraciones de física, etc.
set_input_action → definir mapeos de entrada (move_left → KEY_A, etc.)
add_autoload → registrar singletons autoload
set_physics_layers → nombrar capas de colisión (player, enemy, world, etc.)
```
## Reglas Importantes y Trampas
### Valores de Propiedades
Las propiedades se parsean automáticamente desde strings. Usa estos formatos:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` o `"#ff0000"`
- Bool: `"true"` / `"false"`
- Números: `"42"`, `"3.14"`
- Enums: Usa valores enteros (ej: `0` para el primer valor del enum)
### Nunca Edites project.godot Directamente
El editor de Godot sobrescribe `project.godot` constantemente. Siempre usa `set_project_setting` para cambiar configuraciones del proyecto.
### Anotaciones de Tipo en GDScript
Al escribir GDScript con loops `for` sobre arrays sin tipo, usa anotaciones de tipo explícitas:
```gdscript
# MAL — causará errores
for item in some_untyped_array:
var x := item.value # la inferencia de tipos falla
# BIEN
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Los Cambios en Scripts Necesitan Reload
Después de crear o modificar scripts significativamente, usa `reload_project` para asegurar que Godot reconozca los cambios. Esto es especialmente importante después de `create_script`.
### Consejos para simulate_key
- Usa **duraciones cortas** (0.30.5 segundos) para movimiento preciso
- Duraciones largas (1+ segundo) causan overshooting
- Para pruebas de gameplay, prefiere `simulate_action` sobre `simulate_key` cuando haya acciones del InputMap definidas
### simulate_mouse_click
- El valor por defecto `auto_release: true` envía press y release — requerido para botones de UI
- Los botones de UI se activan en release, por lo que ambos eventos son necesarios
### Limitaciones de execute_game_script
- Sin funciones anidadas (`func` dentro de `func`) — causa error de compilación
- Usa `.get("property")` en lugar de `.property` para acceso dinámico
- Los errores de runtime pausan el debugger (se continúa automáticamente, pero evítalo si es posible)
### Colisión y Áreas de Recolección
- Para ítems recolectables, usa Area3D/Area2D con radio >= 1.5
- Radios más pequeños son casi imposibles de activar con entrada simulada
### Guarda Frecuentemente
Llama a `save_scene` después de hacer cambios significativos. Los cambios no guardados pueden perderse si el editor se recarga.
## Herramientas de Análisis y Depuración
Cuando algo sale mal, usa estas herramientas para investigar:
```
get_editor_errors → revisar errores de script y excepciones de runtime
get_output_log → leer salida de print() y advertencias
analyze_scene_complexity → encontrar cuellos de botella de rendimiento
analyze_signal_flow → visualizar conexiones de signals
detect_circular_dependencies → encontrar referencias circulares de script/escena
find_unused_resources → limpiar archivos no utilizados
get_performance_monitors → FPS, memoria, draw calls, estadísticas de física
```
## Pruebas y QA
```
run_test_scenario → definir y ejecutar secuencias de prueba automatizadas
assert_node_state → verificar que las propiedades de nodos coincidan con valores esperados
assert_screen_text → verificar que el texto se muestre en pantalla
compare_screenshots → pruebas de regresión visual (usa rutas de archivo, no base64)
run_stress_test → generar muchos nodos para probar rendimiento
```
## Patrones Avanzados
### Operaciones entre Escenas
```
cross_scene_set_property → modificar nodos en escenas que no están abiertas actualmente
find_node_references → encontrar todos los archivos que referencian un patrón
batch_set_property → establecer una propiedad en todos los nodos de un tipo
```
### Flujo de Trabajo con Shaders
```
create_shader → escribir código shader estilo GLSL
assign_shader_material → aplicar a un nodo
set_shader_param → ajustar uniforms en runtime
get_shader_params → inspeccionar valores actuales
```
### Navegación (3D)
```
setup_navigation_region → definir área transitable
bake_navigation_mesh → generar navmesh
setup_navigation_agent → agregar pathfinding a personajes
```
### AnimationTree y Máquinas de Estado
```
create_animation_tree → configurar AnimationTree con máquina de estado o blend tree
add_state_machine_state → agregar estados (idle, walk, run, jump)
add_state_machine_transition → definir transiciones entre estados
set_tree_parameter → controlar parámetros de blend
```
## Orden de Flujo de Trabajo Recomendado
Al construir un juego nuevo desde cero:
1. **Configuración del proyecto**`get_project_info`, `set_project_setting` (viewport, física)
2. **Mapeo de entrada**`set_input_action` para todos los controles del jugador
3. **Escena principal**`create_scene`, establecer como escena principal
4. **Jugador** — crear escena del jugador con sprite, colisión, script
5. **Nivel/Mundo** — construir el entorno (TileMap, meshes 3D, etc.)
6. **Lógica del juego** — scripts para enemigos, ítems, UI
7. **Audio** — configurar buses, agregar audio players
8. **Playtesting**`play_scene`, probar con entrada simulada, corregir bugs
9. **Pulido** — animaciones, partículas, shaders, temas
10. **Exportación**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | हिन्दी
# Godot MCP Pro — AI Assistants के लिए Skills
> इस फ़ाइल को अपने Godot प्रोजेक्ट रूट में `.claude/skills.md` पर कॉपी करें ताकि Claude Code को Godot MCP Pro को प्रभावी ढंग से उपयोग करने का पूरा context मिल सके।
## Godot MCP Pro क्या है?
आपके पास 169 MCP tools उपलब्ध हैं जो सीधे Godot 4 editor से कनेक्ट होते हैं। आप scenes बना सकते हैं, scripts लिख सकते हैं, player input simulate कर सकते हैं, running games को inspect कर सकते हैं, और बहुत कुछ — सब कुछ बिना user को इस conversation से बाहर जाए। हर बदलाव Godot के UndoRedo system से होता है, इसलिए user हमेशा Ctrl+Z कर सकता है।
## ज़रूरी Workflows
### 1. प्रोजेक्ट को Explore करें
बदलाव करने से पहले हमेशा प्रोजेक्ट को समझें:
```
get_project_info → प्रोजेक्ट का नाम, Godot version, renderer, viewport size
get_filesystem_tree → directory structure (filter: "*.tscn" या "*.gd" use करें)
get_scene_tree → currently open scene की node hierarchy
read_script → कोई भी GDScript फ़ाइल पढ़ें
get_project_settings → project configuration चेक करें
```
### 2. 2D Scene बनाएं
```
create_scene → root node type के साथ .tscn फ़ाइल बनाएं
add_node → properties के साथ child nodes जोड़ें
create_script → game logic के लिए GDScript लिखें
attach_script → node पर script attach करें
update_property → position, scale, modulate आदि सेट करें
save_scene → disk पर save करें
```
**उदाहरण — player बनाना:**
1. `create_scene` root_type `CharacterBody2D` के साथ, path `res://scenes/player.tscn`
2. `add_node` type `Sprite2D` texture property के साथ
3. `add_node` type `CollisionShape2D`
4. `add_resource` CollisionShape2D को shape assign करने के लिए (जैसे `RectangleShape2D`)
5. `create_script` movement logic के साथ
6. `attach_script` root node पर
7. `save_scene`
### 3. 3D Scene बनाएं
```
create_scene → root_type: Node3D
add_mesh_instance → primitives (box, sphere, cylinder, plane) जोड़ें या .glb/.gltf import करें
setup_lighting → DirectionalLight3D, OmniLight3D, या SpotLight3D जोड़ें
setup_environment → sky, ambient light, fog, tonemap
setup_camera_3d → camera, optional SpringArm3D के साथ third-person के लिए
set_material_3d → PBR materials (albedo, metallic, roughness, emission)
setup_collision → physics bodies में collision shapes जोड़ें
setup_physics_body → mass, friction, gravity configure करें
```
### 4. Scripts लिखें और Edit करें
```
create_script → नई .gd फ़ाइल बनाएं (पूरा content दें)
edit_script → existing scripts modify करें
- `replacements: [{search: "old code", replace: "new code"}]` targeted edits के लिए
- `content` पूरी फ़ाइल replace करने के लिए
- `insert_at_line` + `text` code insert करने के लिए
validate_script → बिना run किए syntax errors चेक करें
read_script → edit करने से पहले current content पढ़ें
```
### 5. Playtest और Debug करें
```
play_scene → game launch करें (mode: "current", "main", या file path)
get_game_screenshot → अभी game कैसा दिख रहा है देखें
capture_frames → motion/animation observe करने के लिए multiple frames capture करें
get_game_scene_tree → runtime पर live scene tree inspect करें
get_game_node_properties → runtime values पढ़ें (position, health, state आदि)
set_game_node_property → running game में values modify करें
simulate_key → keys press करें (WASD, SPACE आदि) duration के साथ
simulate_mouse_click → viewport coordinates पर click करें
simulate_action → InputMap actions trigger करें (move_left, jump आदि)
get_editor_errors → runtime errors चेक करें
stop_scene → game बंद करें
```
**Playtesting loop:**
1. `play_scene` → game शुरू करें
2. `get_game_screenshot` → current state देखें
3. `simulate_key` / `simulate_action` → game के साथ interact करें
4. `capture_frames` → समय के साथ behavior observe करें
5. `get_game_node_properties` → specific values चेक करें
6. `stop_scene` → काम हो जाए तो बंद करें
7. Scripts में issues fix करें → दोहराएं
### 6. Animations
```
# Scene में AnimationPlayer node होना ज़रूरी है
create_animation → length और loop mode के साथ नई animation
add_animation_track → property/transform/method tracks जोड़ें
set_animation_keyframe → specific times पर keyframes insert करें
get_animation_info → existing animations inspect करें
```
**उदाहरण — bouncing sprite:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect आदि
set_anchor_preset → Controls position करें (full_rect, center, bottom_wide आदि)
set_theme_color → font_color आदि बदलें
set_theme_font_size → text size adjust करें
set_theme_stylebox → backgrounds, borders, rounded corners
connect_signal → button pressed, value_changed आदि wire up करें
```
### 8. TileMap
```
tilemap_get_info → tile set sources और atlas layout चेक करें
tilemap_set_cell → individual tiles place करें
tilemap_fill_rect → rectangular regions fill करें
tilemap_get_used_cells → देखें क्या पहले से placed है
tilemap_clear → सभी cells clear करें
```
### 9. Audio
```
add_audio_bus → audio buses बनाएं (SFX, Music, UI)
set_audio_bus → volume, solo, mute adjust करें
add_audio_bus_effect → reverb, delay, compressor आदि जोड़ें
add_audio_player → AudioStreamPlayer(2D/3D) nodes जोड़ें
```
### 10. Project Configuration
```
set_project_setting → viewport size, physics settings आदि बदलें
set_input_action → input mappings define करें (move_left → KEY_A आदि)
add_autoload → autoload singletons register करें
set_physics_layers → collision layers name करें (player, enemy, world आदि)
```
## ज़रूरी Rules और Pitfalls
### Property Values
Properties strings से auto-parse होती हैं। ये formats use करें:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` या `"#ff0000"`
- Bool: `"true"` / `"false"`
- Numbers: `"42"`, `"3.14"`
- Enums: Integer values use करें (जैसे पहले enum value के लिए `0`)
### project.godot को कभी सीधे Edit न करें
Godot editor लगातार `project.godot` को overwrite करता है। Project settings बदलने के लिए हमेशा `set_project_setting` use करें।
### GDScript Type Annotations
Untyped arrays पर `for` loops लिखते समय, explicit type annotations use करें:
```gdscript
# गलत — errors आएंगे
for item in some_untyped_array:
var x := item.value # type inference fail होता है
# सही
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Script Changes के लिए Reload ज़रूरी
Scripts create या significantly modify करने के बाद, `reload_project` use करें ताकि Godot changes को pick up करे। `create_script` के बाद ये खासकर ज़रूरी है।
### simulate_key Tips
- Precise movement के लिए **छोटी duration** (0.30.5 seconds) use करें
- लंबी duration (1+ second) से overshooting होती है
- Gameplay testing के लिए, जब InputMap actions defined हों तो `simulate_key` की जगह `simulate_action` prefer करें
### simulate_mouse_click
- Default `auto_release: true` press और release दोनों भेजता है — UI buttons के लिए ज़रूरी है
- UI buttons release पर fire होते हैं, इसलिए दोनों events चाहिए
### execute_game_script की Limitations
- Nested functions (`func` के अंदर `func`) नहीं चलतीं — compile error आता है
- Dynamic access के लिए `.property` की जगह `.get("property")` use करें
- Runtime errors debugger को pause करती हैं (auto-continue होता है, लेकिन बचना बेहतर)
### Collision और Pickup Areas
- Collectible items के लिए Area3D/Area2D radius >= 1.5 रखें
- छोटे radius को simulated input से trigger करना लगभग impossible है
### बार-बार Save करें
बड़े बदलावों के बाद `save_scene` call करें। Unsaved changes editor reload होने पर खो सकते हैं।
## Analysis और Debugging Tools
कुछ गलत होने पर, इन tools से investigate करें:
```
get_editor_errors → script errors और runtime exceptions चेक करें
get_output_log → print() output और warnings पढ़ें
analyze_scene_complexity → performance bottlenecks खोजें
analyze_signal_flow → signal connections visualize करें
detect_circular_dependencies → circular script/scene references खोजें
find_unused_resources → unused files clean up करें
get_performance_monitors → FPS, memory, draw calls, physics stats
```
## Testing और QA
```
run_test_scenario → automated test sequences define और run करें
assert_node_state → verify करें कि node properties expected values से match करती हैं
assert_screen_text → verify करें कि text screen पर display हो रहा है
compare_screenshots → visual regression testing (file paths use करें, base64 नहीं)
run_stress_test → performance test के लिए बहुत सारे nodes spawn करें
```
## Advanced Patterns
### Cross-Scene Operations
```
cross_scene_set_property → उन scenes के nodes modify करें जो अभी open नहीं हैं
find_node_references → किसी pattern को reference करने वाली सभी files खोजें
batch_set_property → किसी type के सभी nodes पर property set करें
```
### Shader Workflow
```
create_shader → GLSL-like shader code लिखें
assign_shader_material → node पर apply करें
set_shader_param → runtime पर uniforms adjust करें
get_shader_params → current values inspect करें
```
### Navigation (3D)
```
setup_navigation_region → walkable area define करें
bake_navigation_mesh → navmesh generate करें
setup_navigation_agent → characters में pathfinding जोड़ें
```
### AnimationTree और State Machines
```
create_animation_tree → state machine या blend tree के साथ AnimationTree set up करें
add_state_machine_state → states जोड़ें (idle, walk, run, jump)
add_state_machine_transition → states के बीच transitions define करें
set_tree_parameter → blend parameters control करें
```
## Recommended Workflow Order
नया game scratch से बनाते समय:
1. **Project setup**`get_project_info`, `set_project_setting` (viewport, physics)
2. **Input mapping**`set_input_action` सभी player controls के लिए
3. **Main scene**`create_scene`, main scene के रूप में set करें
4. **Player** — sprite, collision, script के साथ player scene बनाएं
5. **Level/World** — environment build करें (TileMap, 3D meshes आदि)
6. **Game logic** — enemies, items, UI के लिए scripts
7. **Audio** — buses set up करें, audio players जोड़ें
8. **Playtest**`play_scene`, simulated input से test करें, bugs fix करें
9. **Polish** — animations, particles, shaders, themes
10. **Export**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,276 @@
> **Language:** [English](skills.md) | 日本語 | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — AIアシスタント向けスキル
> このファイルをGodotプロジェクトルートの `.claude/skills.md` にコピーすると、Claude CodeがGodot MCP Proを効果的に活用するためのコンテキストを得られます。
## Godot MCP Proとは
Godot 4エディタに直接接続する169のMCPツールを利用できます。シーンの作成、スクリプトの記述、プレイヤー入力のシミュレーション、実行中のゲームの検査など、ユーザーがこの会話から離れることなく、すべての操作が可能です。すべての変更はGodotのUndoRedoシステムを通じて行われるため、いつでもCtrl+Zで元に戻せます。
## 基本ワークフロー
### 1. プロジェクトの調査
変更を加える前に、まずプロジェクトの全体像を把握しましょう:
```
get_project_info → プロジェクト名、Godotバージョン、レンダラー、ビューポートサイズ
get_filesystem_tree → ディレクトリ構造filter: "*.tscn" や "*.gd" が使えます)
get_scene_tree → 現在開いているシーンのノード階層
read_script → 任意のGDScriptファイルを読む
get_project_settings → プロジェクト設定の確認
```
### 2. 2Dシーンの構築
```
create_scene → .tscnファイルをルートードタイプ指定で作成
add_node → プロパティ付きの子ノードを追加
create_script → ゲームロジック用のGDScriptを作成
attach_script → ノードにスクリプトをアタッチ
update_property → position、scale、modulateなどを設定
save_scene → ディスクに保存
```
**例 — プレイヤーの作成:**
1. `create_scene` でroot_type `CharacterBody2D`、path `res://scenes/player.tscn` を指定
2. `add_node` でtextureプロパティ付きの `Sprite2D` を追加
3. `add_node``CollisionShape2D` を追加
4. `add_resource` でCollisionShape2Dにシェイプ`RectangleShape2D`)を割り当て
5. `create_script` で移動ロジックを記述
6. `attach_script` でルートノードにアタッチ
7. `save_scene`
### 3. 3Dシーンの構築
```
create_scene → root_type: Node3D
add_mesh_instance → プリミティブbox、sphere、cylinder、planeの追加、または.glb/.gltfのインポート
setup_lighting → DirectionalLight3D、OmniLight3D、SpotLight3Dの追加
setup_environment → スカイ、アンビエントライト、フォグ、トーンマップ
setup_camera_3d → カメラオプションでSpringArm3Dによる三人称視点
set_material_3d → PBRマテリアルalbedo、metallic、roughness、emission
setup_collision → 物理ボディにコリジョンシェイプを追加
setup_physics_body → 質量、摩擦、重力の設定
```
### 4. スクリプトの作成と編集
```
create_script → 新規.gdファイルを作成完全な内容を提供
edit_script → 既存スクリプトを編集
- `replacements: [{search: "old code", replace: "new code"}]` で部分的な編集
- `content` でファイル全体を置換
- `insert_at_line` + `text` でコードを挿入
validate_script → 実行せずに構文エラーをチェック
read_script → 編集前に現在の内容を確認
```
### 5. プレイテストとデバッグ
```
play_scene → ゲームを起動mode: "current"、"main"、またはファイルパス)
get_game_screenshot → ゲームの現在の見た目を確認
capture_frames → 複数フレームをキャプチャして動きやアニメーションを観察
get_game_scene_tree → 実行時のシーンツリーを検査
get_game_node_properties → ランタイムの値を読み取りposition、health、stateなど
set_game_node_property → 実行中のゲームの値を変更
simulate_key → キー入力WASD、SPACEなどをduration指定で実行
simulate_mouse_click → ビューポート座標でクリック
simulate_action → InputMapアクションmove_left、jumpなどをトリガー
get_editor_errors → ランタイムエラーの確認
stop_scene → ゲームを停止
```
**プレイテストループ:**
1. `play_scene` → ゲームを開始
2. `get_game_screenshot` → 現在の状態を確認
3. `simulate_key` / `simulate_action` → ゲームを操作
4. `capture_frames` → 時間経過での挙動を観察
5. `get_game_node_properties` → 特定の値を確認
6. `stop_scene` → 完了したら停止
7. スクリプトの問題を修正 → 繰り返し
### 6. アニメーション
```
# シーンにAnimationPlayerードが存在することを確認
create_animation → 長さとループモード付きの新規アニメーション
add_animation_track → property/transform/methodトラックの追加
set_animation_keyframe → 特定時間にキーフレームを挿入
get_animation_info → 既存アニメーションの情報を取得
```
**例 — バウンドするスプライト:**
1. `create_animation` でname `bounce`、length `1.0`、loop_mode `1`(リニアループ)
2. `add_animation_track` でtrack_path `Sprite2D:position`、track_type `value`
3. `set_animation_keyframe` でtime `0.0`、value `Vector2(0, 0)`
4. `set_animation_keyframe` でtime `0.5`、value `Vector2(0, -50)`
5. `set_animation_keyframe` でtime `1.0`、value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control、Label、Button、TextureRectなど
set_anchor_preset → Controlの配置full_rect、center、bottom_wideなど
set_theme_color → font_colorなどの変更
set_theme_font_size → テキストサイズの調整
set_theme_stylebox → 背景、ボーダー、角丸
connect_signal → buttonのpressed、value_changedなどを接続
```
### 8. TileMap
```
tilemap_get_info → タイルセットのソースとアトラスレイアウトを確認
tilemap_set_cell → 個別タイルの配置
tilemap_fill_rect → 矩形領域を塗りつぶし
tilemap_get_used_cells → 配置済みセルの確認
tilemap_clear → 全セルをクリア
```
### 9. オーディオ
```
add_audio_bus → オーディオバスの作成SFX、Music、UI
set_audio_bus → ボリューム、ソロ、ミュートの調整
add_audio_bus_effect → リバーブ、ディレイ、コンプレッサーなどの追加
add_audio_player → AudioStreamPlayer(2D/3D)ノードの追加
```
### 10. プロジェクト設定
```
set_project_setting → ビューポートサイズ、物理設定などの変更
set_input_action → 入力マッピングの定義move_left → KEY_Aなど
add_autoload → Autoloadシングルトンの登録
set_physics_layers → コリジョンレイヤーの命名player、enemy、worldなど
```
## 重要なルールと注意点
### プロパティ値
プロパティは文字列から自動パースされます。以下のフォーマットを使用してください:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` または `"#ff0000"`
- Bool: `"true"` / `"false"`
- 数値: `"42"``"3.14"`
- Enum: 整数値を使用最初のenum値は `0`
### project.godotを直接編集しないこと
Godotエディタは `project.godot` を常に上書きします。プロジェクト設定の変更には必ず `set_project_setting` を使用してください。
### GDScriptの型アテーション
型なし配列に対する `for` ループでは、明示的な型アノテーションを使用してください:
```gdscript
# NG — エラーの原因になる
for item in some_untyped_array:
var x := item.value # 型推論が失敗
# OK
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### スクリプト変更にはリロードが必要
スクリプトの作成や大幅な変更の後は、`reload_project` を使用してGodotに変更を反映させましょう。特に `create_script` の後は重要です。
### simulate_keyのコツ
- 精密な移動には**短いduration**0.3〜0.5秒)を使用
- 長いduration1秒以上はオーバーシュートの原因に
- ゲームプレイのテストでは、InputMapアクションが定義されている場合は `simulate_key` より `simulate_action` を推奨
### simulate_mouse_click
- デフォルトの `auto_release: true` はpressとreleaseの両方を送信 — UIボタンに必須
- UIボタンはreleaseで発火するため、両方のイベントが必要
### execute_game_scriptの制限事項
- ネストされた関数(`func` 内の `func`)は不可 — コンパイルエラーになる
- 動的アクセスには `.property` ではなく `.get("property")` を使用
- ランタイムエラーはデバッガーを一時停止させる(自動再開されるが、できれば避けること)
### コリジョンとピックアップエリア
- 収集アイテムにはArea3D/Area2Dで半径1.5以上を使用
- 小さい半径ではシミュレーション入力でのトリガーがほぼ不可能
### こまめに保存する
大きな変更を行った後は `save_scene` を呼んでください。保存していない変更はエディタのリロード時に失われる可能性があります。
## 分析とデバッグツール
問題が発生した場合、以下のツールで調査できます:
```
get_editor_errors → スクリプトエラーとランタイム例外を確認
get_output_log → print()出力と警告を読む
analyze_scene_complexity → パフォーマンスのボトルネックを特定
analyze_signal_flow → シグナル接続を可視化
detect_circular_dependencies → 循環参照するスクリプト/シーンを検出
find_unused_resources → 未使用ファイルのクリーンアップ
get_performance_monitors → FPS、メモリ、ドローコール、物理統計
```
## テストとQA
```
run_test_scenario → 自動テストシーケンスの定義と実行
assert_node_state → ノードプロパティが期待値と一致するか検証
assert_screen_text → 画面にテキストが表示されているか検証
compare_screenshots → ビジュアル回帰テストbase64ではなくファイルパスを使用
run_stress_test → 多数のノードを生成してパフォーマンスをテスト
```
## 高度なパターン
### クロスシーン操作
```
cross_scene_set_property → 現在開いていないシーンのノードを変更
find_node_references → パターンを参照しているすべてのファイルを検索
batch_set_property → 特定タイプの全ノードにプロパティを設定
```
### シェーダーワークフロー
```
create_shader → GLSL風のシェーダーコードを記述
assign_shader_material → ノードに適用
set_shader_param → 実行時にuniformを調整
get_shader_params → 現在の値を取得
```
### ナビゲーション3D
```
setup_navigation_region → 歩行可能エリアの定義
bake_navigation_mesh → ナビメッシュの生成
setup_navigation_agent → キャラクターにパスファインディングを追加
```
### AnimationTreeとステートマシン
```
create_animation_tree → ステートマシンまたはブレンドツリーでAnimationTreeをセットアップ
add_state_machine_state → ステートを追加idle、walk、run、jump
add_state_machine_transition → ステート間のトランジションを定義
set_tree_parameter → ブレンドパラメータを制御
```
## 推奨ワークフロー順序
ゲームをゼロから構築する場合の推奨順序:
1. **プロジェクトセットアップ**`get_project_info``set_project_setting`(ビューポート、物理)
2. **入力マッピング**`set_input_action` で全プレイヤー操作を定義
3. **メインシーン**`create_scene` でメインシーンとして設定
4. **プレイヤー** — スプライト、コリジョン、スクリプト付きのプレイヤーシーンを作成
5. **レベル/ワールド** — 環境を構築TileMap、3Dメッシュなど
6. **ゲームロジック** — 敵、アイテム、UIのスクリプト
7. **オーディオ** — バスのセットアップ、オーディオプレイヤーの追加
8. **プレイテスト**`play_scene` でシミュレーション入力によるテスト、バグ修正
9. **ポリッシュ** — アニメーション、パーティクル、シェーダー、テーマ
10. **エクスポート**`list_export_presets``export_project`

View File

@@ -0,0 +1,295 @@
> **Language:** English | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills for AI Assistants
> Copy this file to `.claude/skills.md` in your Godot project root to give Claude Code full context on how to use Godot MCP Pro effectively.
## What is Godot MCP Pro?
You have access to 169 MCP tools that connect directly to the Godot 4 editor. You can create scenes, write scripts, simulate player input, inspect running games, and more — all without the user leaving this conversation. Every change goes through Godot's UndoRedo system, so the user can always Ctrl+Z.
## Essential Workflows
### 1. Explore a Project
Always start by understanding the project before making changes:
```
get_project_info → project name, Godot version, renderer, viewport size
get_filesystem_tree → directory structure (use filter: "*.tscn" or "*.gd")
get_scene_tree → node hierarchy of the currently open scene
read_script → read any GDScript file
get_project_settings → check project configuration
```
### 2. Build a 2D Scene
```
create_scene → create .tscn file with root node type
add_node → add child nodes with properties
create_script → write GDScript for game logic
attach_script → attach script to a node
update_property → set position, scale, modulate, etc.
save_scene → save to disk
```
**Example — creating a player:**
1. `create_scene` with root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` type `Sprite2D` with texture property
3. `add_node` type `CollisionShape2D`
4. `add_resource` to assign a shape (e.g., `RectangleShape2D`) to the CollisionShape2D
5. `create_script` with movement logic
6. `attach_script` to the root node
7. `save_scene`
### 3. Build a 3D Scene
```
create_scene → root_type: Node3D
add_mesh_instance → add primitives (box, sphere, cylinder, plane) or import .glb/.gltf
setup_lighting → add DirectionalLight3D, OmniLight3D, or SpotLight3D
setup_environment → sky, ambient light, fog, tonemap
setup_camera_3d → camera with optional SpringArm3D for third-person
set_material_3d → PBR materials (albedo, metallic, roughness, emission)
setup_collision → add collision shapes to physics bodies
setup_physics_body → configure mass, friction, gravity
```
### 4. Write & Edit Scripts
```
create_script → create new .gd file (provide full content)
edit_script → modify existing scripts
- Use `replacements: [{search: "old code", replace: "new code"}]` for targeted edits
- Use `content` for full file replacement
- Use `insert_at_line` + `text` for inserting code
validate_script → check for syntax errors without running
read_script → read current content before editing
```
### 5. Playtest & Debug
```
play_scene → launch the game (mode: "current", "main", or file path)
get_game_screenshot → see what the game looks like right now
capture_frames → capture multiple frames to observe motion/animation
get_game_scene_tree → inspect the live scene tree at runtime
get_game_node_properties → read runtime values (position, health, state, etc.)
set_game_node_property → modify values in the running game
simulate_key → press keys (WASD, SPACE, etc.) with duration
simulate_mouse_click → click at viewport coordinates
simulate_action → trigger InputMap actions (move_left, jump, etc.)
get_editor_errors → check for runtime errors
stop_scene → stop the game
```
**Playtesting loop:**
1. `play_scene` → start the game
2. `get_game_screenshot` → see current state
3. `simulate_key` / `simulate_action` → interact with the game
4. `capture_frames` → observe behavior over time
5. `get_game_node_properties` → check specific values
6. `stop_scene` → stop when done
7. Fix issues in scripts → repeat
### 6. Animations
```
# Ensure an AnimationPlayer node exists in the scene
create_animation → new animation with length and loop mode
add_animation_track → add property/transform/method tracks
set_animation_keyframe → insert keyframes at specific times
get_animation_info → inspect existing animations
```
**Example — bouncing sprite:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → position Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → change font_color, etc.
set_theme_font_size → adjust text size
set_theme_stylebox → backgrounds, borders, rounded corners
connect_signal → wire up button pressed, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → check tile set sources and atlas layout
tilemap_set_cell → place individual tiles
tilemap_fill_rect → fill rectangular regions
tilemap_get_used_cells → see what's already placed
tilemap_clear → clear all cells
```
### 9. Audio
```
add_audio_bus → create audio buses (SFX, Music, UI)
set_audio_bus → adjust volume, solo, mute
add_audio_bus_effect → add reverb, delay, compressor, etc.
add_audio_player → add AudioStreamPlayer(2D/3D) nodes
```
### 10. Project Configuration
```
set_project_setting → change viewport size, physics settings, etc.
set_input_action → define input mappings (move_left → KEY_A, etc.)
add_autoload → register autoload singletons
set_physics_layers → name collision layers (player, enemy, world, etc.)
```
## Important Rules & Pitfalls
### Prefer Inspector Properties Over Code
When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak by hand. Only write GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
### Property Values
Properties are auto-parsed from strings. Use these formats:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` or `"#ff0000"`
- Bool: `"true"` / `"false"`
- Numbers: `"42"`, `"3.14"`
- Enums: Use integer values (e.g., `0` for the first enum value)
### Never Edit project.godot Directly
Godot editor constantly overwrites `project.godot`. Always use `set_project_setting` to change project settings.
### GDScript Type Annotations
When writing GDScript with `for` loops over untyped arrays, use explicit type annotations:
```gdscript
# BAD — will cause errors
for item in some_untyped_array:
var x := item.value # type inference fails
# GOOD
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Script Changes Need Reload
After creating or significantly modifying scripts, use `reload_project` to ensure Godot picks up the changes. This is especially important after `create_script`.
### simulate_key Tips
- Use **short durations** (0.30.5 seconds) for precise movement
- Long durations (1+ seconds) cause overshooting
- For gameplay testing, prefer `simulate_action` over `simulate_key` when InputMap actions are defined
### simulate_mouse_click
- Default `auto_release: true` sends both press and release — required for UI buttons
- UI buttons fire on release, so both events are needed
### execute_game_script Limitations
- No nested functions (`func` inside `func`) — causes compile error
- Use `.get("property")` instead of `.property` for dynamic access
- Runtime errors will pause the debugger (auto-continued, but avoid if possible)
### Collision & Pickup Areas
- For collectible items, use Area3D/Area2D with radius ≥ 1.5
- Smaller radii are nearly impossible to trigger with simulated input
### Save Frequently
Call `save_scene` after making significant changes. Unsaved changes can be lost if the editor reloads.
## Analysis & Debugging Tools
When something goes wrong, use these tools to investigate:
```
get_editor_errors → check for script errors and runtime exceptions
get_output_log → read print() output and warnings
analyze_scene_complexity → find performance bottlenecks
analyze_signal_flow → visualize signal connections
detect_circular_dependencies → find circular script/scene references
find_unused_resources → clean up unused files
get_performance_monitors → FPS, memory, draw calls, physics stats
```
## Testing & QA
```
run_test_scenario → define and run automated test sequences
assert_node_state → verify node properties match expected values
assert_screen_text → verify text is displayed on screen
compare_screenshots → visual regression testing (use file paths, not base64)
run_stress_test → spawn many nodes to test performance
```
## Advanced Patterns
### Cross-Scene Operations
```
cross_scene_set_property → modify nodes in scenes that aren't currently open
find_node_references → find all files referencing a pattern
batch_set_property → set a property on all nodes of a type
```
### Shader Workflow
```
create_shader → write GLSL-like shader code
assign_shader_material → apply to a node
set_shader_param → adjust uniforms at runtime
get_shader_params → inspect current values
```
### Navigation (3D)
```
setup_navigation_region → define walkable area
bake_navigation_mesh → generate navmesh
setup_navigation_agent → add pathfinding to characters
```
### AnimationTree & State Machines
```
create_animation_tree → set up AnimationTree with state machine or blend tree
add_state_machine_state → add states (idle, walk, run, jump)
add_state_machine_transition → define transitions between states
set_tree_parameter → control blend parameters
```
### Code-to-Inspector Migration
Move hardcoded visual properties from GDScript to the inspector for easier tweaking:
```
read_script → find hardcoded property assignments
get_node_properties → check current inspector values
update_property → set values as node properties
edit_script → remove hardcoded lines from script
save_scene → persist inspector changes
validate_script → verify script still compiles
```
Example — a script sets `modulate = Color(1, 0, 0, 1)` in `_ready()`:
1. `read_script` to find the line
2. `update_property` with `node_path`, `property: "modulate"`, `value: "Color(1, 0, 0, 1)"`
3. `edit_script` to remove the `modulate = ...` line from `_ready()`
4. `save_scene` + `validate_script`
This applies to: colors, positions, sizes, theme overrides, material properties, visibility, margins, anchors, and any property that doesn't need to change at runtime.
## Recommended Workflow Order
When building a new game from scratch:
1. **Project setup**`get_project_info`, `set_project_setting` (viewport, physics)
2. **Input mapping**`set_input_action` for all player controls
3. **Main scene**`create_scene`, set as main scene
4. **Player** — create player scene with sprite, collision, script
5. **Level/World** — build environment (TileMap, 3D meshes, etc.)
6. **Game logic** — scripts for enemies, items, UI
7. **Audio** — set up buses, add audio players
8. **Playtest**`play_scene`, test with simulated input, fix bugs
9. **Polish** — animations, particles, shaders, themes
10. **Export**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | Português (BR) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Skills para Assistentes de IA
> Copie este arquivo para `.claude/skills.md` na raiz do seu projeto Godot para dar ao Claude Code o contexto completo de como usar o Godot MCP Pro de forma eficiente.
## O que é o Godot MCP Pro?
Você tem acesso a 169 ferramentas MCP que se conectam diretamente ao editor Godot 4. Você pode criar cenas, escrever scripts, simular entrada do jogador, inspecionar jogos em execução e muito mais — tudo sem que o usuário precise sair desta conversa. Todas as alterações passam pelo sistema UndoRedo do Godot, então o usuário pode sempre usar Ctrl+Z.
## Fluxos de Trabalho Essenciais
### 1. Explorar um Projeto
Sempre comece entendendo o projeto antes de fazer alterações:
```
get_project_info → nome do projeto, versão do Godot, renderizador, tamanho do viewport
get_filesystem_tree → estrutura de diretórios (use filter: "*.tscn" ou "*.gd")
get_scene_tree → hierarquia de nós da cena atualmente aberta
read_script → ler qualquer arquivo GDScript
get_project_settings → verificar configuração do projeto
```
### 2. Construir uma Cena 2D
```
create_scene → criar arquivo .tscn com tipo de nó raiz
add_node → adicionar nós filhos com propriedades
create_script → escrever GDScript para lógica do jogo
attach_script → anexar script a um nó
update_property → definir position, scale, modulate, etc.
save_scene → salvar no disco
```
**Exemplo — criando um jogador:**
1. `create_scene` com root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` tipo `Sprite2D` com propriedade texture
3. `add_node` tipo `CollisionShape2D`
4. `add_resource` para atribuir uma shape (ex: `RectangleShape2D`) ao CollisionShape2D
5. `create_script` com lógica de movimento
6. `attach_script` ao nó raiz
7. `save_scene`
### 3. Construir uma Cena 3D
```
create_scene → root_type: Node3D
add_mesh_instance → adicionar primitivas (box, sphere, cylinder, plane) ou importar .glb/.gltf
setup_lighting → adicionar DirectionalLight3D, OmniLight3D ou SpotLight3D
setup_environment → céu, luz ambiente, neblina, tonemap
setup_camera_3d → câmera com SpringArm3D opcional para terceira pessoa
set_material_3d → materiais PBR (albedo, metallic, roughness, emission)
setup_collision → adicionar shapes de colisão a corpos físicos
setup_physics_body → configurar massa, atrito, gravidade
```
### 4. Escrever e Editar Scripts
```
create_script → criar novo arquivo .gd (forneça o conteúdo completo)
edit_script → modificar scripts existentes
- Use `replacements: [{search: "old code", replace: "new code"}]` para edições pontuais
- Use `content` para substituição completa do arquivo
- Use `insert_at_line` + `text` para inserir código
validate_script → verificar erros de sintaxe sem executar
read_script → ler conteúdo atual antes de editar
```
### 5. Testar e Depurar
```
play_scene → iniciar o jogo (mode: "current", "main" ou caminho do arquivo)
get_game_screenshot → ver como o jogo está neste momento
capture_frames → capturar múltiplos frames para observar movimento/animação
get_game_scene_tree → inspecionar a árvore de cena em tempo de execução
get_game_node_properties → ler valores em runtime (position, health, state, etc.)
set_game_node_property → modificar valores no jogo em execução
simulate_key → pressionar teclas (WASD, SPACE, etc.) com duração
simulate_mouse_click → clicar em coordenadas do viewport
simulate_action → disparar ações do InputMap (move_left, jump, etc.)
get_editor_errors → verificar erros de execução
stop_scene → parar o jogo
```
**Loop de playtesting:**
1. `play_scene` → iniciar o jogo
2. `get_game_screenshot` → ver estado atual
3. `simulate_key` / `simulate_action` → interagir com o jogo
4. `capture_frames` → observar comportamento ao longo do tempo
5. `get_game_node_properties` → verificar valores específicos
6. `stop_scene` → parar quando terminar
7. Corrigir problemas nos scripts → repetir
### 6. Animações
```
# Certifique-se de que existe um nó AnimationPlayer na cena
create_animation → nova animação com duração e modo de loop
add_animation_track → adicionar tracks de property/transform/method
set_animation_keyframe → inserir keyframes em tempos específicos
get_animation_info → inspecionar animações existentes
```
**Exemplo — sprite quicando:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop linear)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect, etc.
set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.)
set_theme_color → alterar font_color, etc.
set_theme_font_size → ajustar tamanho do texto
set_theme_stylebox → fundos, bordas, cantos arredondados
connect_signal → conectar pressed do button, value_changed, etc.
```
### 8. TileMap
```
tilemap_get_info → verificar fontes do tile set e layout do atlas
tilemap_set_cell → colocar tiles individuais
tilemap_fill_rect → preencher regiões retangulares
tilemap_get_used_cells → ver o que já está colocado
tilemap_clear → limpar todas as células
```
### 9. Áudio
```
add_audio_bus → criar buses de áudio (SFX, Music, UI)
set_audio_bus → ajustar volume, solo, mute
add_audio_bus_effect → adicionar reverb, delay, compressor, etc.
add_audio_player → adicionar nós AudioStreamPlayer(2D/3D)
```
### 10. Configuração do Projeto
```
set_project_setting → alterar tamanho do viewport, configurações de física, etc.
set_input_action → definir mapeamentos de entrada (move_left → KEY_A, etc.)
add_autoload → registrar singletons autoload
set_physics_layers → nomear camadas de colisão (player, enemy, world, etc.)
```
## Regras Importantes e Armadilhas
### Valores de Propriedade
Propriedades são parseadas automaticamente de strings. Use estes formatos:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` ou `"#ff0000"`
- Bool: `"true"` / `"false"`
- Números: `"42"`, `"3.14"`
- Enums: Use valores inteiros (ex: `0` para o primeiro valor do enum)
### Nunca Edite project.godot Diretamente
O editor Godot sobrescreve `project.godot` constantemente. Sempre use `set_project_setting` para alterar configurações do projeto.
### Anotações de Tipo em GDScript
Ao escrever GDScript com loops `for` sobre arrays sem tipo, use anotações de tipo explícitas:
```gdscript
# RUIM — vai causar erros
for item in some_untyped_array:
var x := item.value # inferência de tipo falha
# BOM
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Alterações em Scripts Precisam de Reload
Após criar ou modificar scripts significativamente, use `reload_project` para garantir que o Godot reconheça as alterações. Isso é especialmente importante após `create_script`.
### Dicas para simulate_key
- Use **durações curtas** (0.30.5 segundos) para movimentos precisos
- Durações longas (1+ segundo) causam overshooting
- Para testes de gameplay, prefira `simulate_action` em vez de `simulate_key` quando ações do InputMap estiverem definidas
### simulate_mouse_click
- O padrão `auto_release: true` envia press e release — necessário para botões de UI
- Botões de UI disparam no release, então ambos os eventos são necessários
### Limitações do execute_game_script
- Sem funções aninhadas (`func` dentro de `func`) — causa erro de compilação
- Use `.get("property")` em vez de `.property` para acesso dinâmico
- Erros de runtime pausam o debugger (continuado automaticamente, mas evite se possível)
### Colisão e Áreas de Coleta
- Para itens coletáveis, use Area3D/Area2D com raio >= 1.5
- Raios menores são quase impossíveis de acionar com entrada simulada
### Salve com Frequência
Chame `save_scene` após fazer alterações significativas. Alterações não salvas podem ser perdidas se o editor recarregar.
## Ferramentas de Análise e Depuração
Quando algo der errado, use estas ferramentas para investigar:
```
get_editor_errors → verificar erros de script e exceções de runtime
get_output_log → ler saída de print() e avisos
analyze_scene_complexity → encontrar gargalos de performance
analyze_signal_flow → visualizar conexões de signals
detect_circular_dependencies → encontrar referências circulares de script/cena
find_unused_resources → limpar arquivos não utilizados
get_performance_monitors → FPS, memória, draw calls, estatísticas de física
```
## Testes e QA
```
run_test_scenario → definir e executar sequências de teste automatizadas
assert_node_state → verificar se propriedades de nós correspondem aos valores esperados
assert_screen_text → verificar se texto está exibido na tela
compare_screenshots → teste de regressão visual (use caminhos de arquivo, não base64)
run_stress_test → gerar muitos nós para testar performance
```
## Padrões Avançados
### Operações entre Cenas
```
cross_scene_set_property → modificar nós em cenas que não estão abertas atualmente
find_node_references → encontrar todos os arquivos que referenciam um padrão
batch_set_property → definir uma propriedade em todos os nós de um tipo
```
### Fluxo de Trabalho com Shaders
```
create_shader → escrever código shader estilo GLSL
assign_shader_material → aplicar a um nó
set_shader_param → ajustar uniforms em runtime
get_shader_params → inspecionar valores atuais
```
### Navegação (3D)
```
setup_navigation_region → definir área caminhável
bake_navigation_mesh → gerar navmesh
setup_navigation_agent → adicionar pathfinding a personagens
```
### AnimationTree e Máquinas de Estado
```
create_animation_tree → configurar AnimationTree com máquina de estado ou blend tree
add_state_machine_state → adicionar estados (idle, walk, run, jump)
add_state_machine_transition → definir transições entre estados
set_tree_parameter → controlar parâmetros de blend
```
## Ordem de Fluxo de Trabalho Recomendada
Ao construir um novo jogo do zero:
1. **Configuração do projeto**`get_project_info`, `set_project_setting` (viewport, física)
2. **Mapeamento de entrada**`set_input_action` para todos os controles do jogador
3. **Cena principal**`create_scene`, definir como cena principal
4. **Jogador** — criar cena do jogador com sprite, colisão, script
5. **Nível/Mundo** — construir o ambiente (TileMap, meshes 3D, etc.)
6. **Lógica do jogo** — scripts para inimigos, itens, UI
7. **Áudio** — configurar buses, adicionar audio players
8. **Playtesting**`play_scene`, testar com entrada simulada, corrigir bugs
9. **Polimento** — animações, partículas, shaders, temas
10. **Exportação**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | Русский | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md)
# Godot MCP Pro — Навыки для ИИ-ассистентов
> Скопируйте этот файл в `.claude/skills.md` в корне вашего проекта Godot, чтобы дать Claude Code полный контекст по эффективному использованию Godot MCP Pro.
## Что такое Godot MCP Pro?
Вам доступны 169 MCP-инструмента, которые напрямую подключаются к редактору Godot 4. Вы можете создавать сцены, писать скрипты, симулировать ввод игрока, инспектировать запущенные игры и многое другое — всё это без выхода пользователя из данного разговора. Все изменения проходят через систему UndoRedo Godot, поэтому пользователь всегда может нажать Ctrl+Z.
## Основные рабочие процессы
### 1. Изучение проекта
Всегда начинайте с понимания проекта перед внесением изменений:
```
get_project_info → название проекта, версия Godot, рендерер, размер viewport
get_filesystem_tree → структура директорий (используйте filter: "*.tscn" или "*.gd")
get_scene_tree → иерархия нод текущей открытой сцены
read_script → прочитать любой файл GDScript
get_project_settings → проверить конфигурацию проекта
```
### 2. Создание 2D-сцены
```
create_scene → создать файл .tscn с указанием типа корневой ноды
add_node → добавить дочерние ноды со свойствами
create_script → написать GDScript для игровой логики
attach_script → прикрепить скрипт к ноде
update_property → установить position, scale, modulate и т.д.
save_scene → сохранить на диск
```
**Пример — создание игрока:**
1. `create_scene` с root_type `CharacterBody2D`, path `res://scenes/player.tscn`
2. `add_node` типа `Sprite2D` со свойством texture
3. `add_node` типа `CollisionShape2D`
4. `add_resource` для назначения формы (например, `RectangleShape2D`) на CollisionShape2D
5. `create_script` с логикой движения
6. `attach_script` на корневую ноду
7. `save_scene`
### 3. Создание 3D-сцены
```
create_scene → root_type: Node3D
add_mesh_instance → добавить примитивы (box, sphere, cylinder, plane) или импортировать .glb/.gltf
setup_lighting → добавить DirectionalLight3D, OmniLight3D или SpotLight3D
setup_environment → небо, окружающий свет, туман, tonemap
setup_camera_3d → камера с опциональным SpringArm3D для вида от третьего лица
set_material_3d → PBR-материалы (albedo, metallic, roughness, emission)
setup_collision → добавить формы столкновений к физическим телам
setup_physics_body → настроить массу, трение, гравитацию
```
### 4. Написание и редактирование скриптов
```
create_script → создать новый файл .gd (укажите полное содержимое)
edit_script → изменить существующие скрипты
- Используйте `replacements: [{search: "old code", replace: "new code"}]` для точечных правок
- Используйте `content` для полной замены файла
- Используйте `insert_at_line` + `text` для вставки кода
validate_script → проверить синтаксические ошибки без запуска
read_script → прочитать текущее содержимое перед редактированием
```
### 5. Тестирование и отладка
```
play_scene → запустить игру (mode: "current", "main" или путь к файлу)
get_game_screenshot → увидеть, как игра выглядит прямо сейчас
capture_frames → захватить несколько кадров для наблюдения за движением/анимацией
get_game_scene_tree → инспектировать дерево сцены в runtime
get_game_node_properties → прочитать значения в runtime (position, health, state и т.д.)
set_game_node_property → изменить значения в запущенной игре
simulate_key → нажать клавиши (WASD, SPACE и т.д.) с указанием длительности
simulate_mouse_click → кликнуть по координатам viewport
simulate_action → вызвать действия InputMap (move_left, jump и т.д.)
get_editor_errors → проверить ошибки выполнения
stop_scene → остановить игру
```
**Цикл плейтестинга:**
1. `play_scene` → запустить игру
2. `get_game_screenshot` → увидеть текущее состояние
3. `simulate_key` / `simulate_action` → взаимодействовать с игрой
4. `capture_frames` → наблюдать поведение во времени
5. `get_game_node_properties` → проверить конкретные значения
6. `stop_scene` → остановить по завершении
7. Исправить проблемы в скриптах → повторить
### 6. Анимации
```
# Убедитесь, что в сцене есть нода AnimationPlayer
create_animation → новая анимация с длительностью и режимом зацикливания
add_animation_track → добавить треки property/transform/method
set_animation_keyframe → вставить ключевые кадры в определённые моменты
get_animation_info → просмотреть существующие анимации
```
**Пример — прыгающий спрайт:**
1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (линейный цикл)
2. `add_animation_track` track_path `Sprite2D:position`, track_type `value`
3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)`
4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)`
5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control, Label, Button, TextureRect и т.д.
set_anchor_preset → позиционирование Controls (full_rect, center, bottom_wide и т.д.)
set_theme_color → изменить font_color и т.д.
set_theme_font_size → настроить размер текста
set_theme_stylebox → фоны, рамки, скруглённые углы
connect_signal → подключить pressed кнопки, value_changed и т.д.
```
### 8. TileMap
```
tilemap_get_info → проверить источники набора тайлов и раскладку атласа
tilemap_set_cell → разместить отдельные тайлы
tilemap_fill_rect → заполнить прямоугольные области
tilemap_get_used_cells → посмотреть, что уже размещено
tilemap_clear → очистить все ячейки
```
### 9. Аудио
```
add_audio_bus → создать аудио-шины (SFX, Music, UI)
set_audio_bus → настроить громкость, соло, заглушение
add_audio_bus_effect → добавить реверберацию, задержку, компрессор и т.д.
add_audio_player → добавить ноды AudioStreamPlayer(2D/3D)
```
### 10. Конфигурация проекта
```
set_project_setting → изменить размер viewport, настройки физики и т.д.
set_input_action → определить маппинг ввода (move_left → KEY_A и т.д.)
add_autoload → зарегистрировать синглтоны autoload
set_physics_layers → именовать слои столкновений (player, enemy, world и т.д.)
```
## Важные правила и подводные камни
### Значения свойств
Свойства автоматически парсятся из строк. Используйте следующие форматы:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"` или `"#ff0000"`
- Bool: `"true"` / `"false"`
- Числа: `"42"`, `"3.14"`
- Enum: Используйте целочисленные значения (например, `0` для первого значения enum)
### Никогда не редактируйте project.godot напрямую
Редактор Godot постоянно перезаписывает `project.godot`. Всегда используйте `set_project_setting` для изменения настроек проекта.
### Аннотации типов в GDScript
При написании GDScript с циклами `for` по нетипизированным массивам используйте явные аннотации типов:
```gdscript
# ПЛОХО — приведёт к ошибкам
for item in some_untyped_array:
var x := item.value # вывод типов не работает
# ХОРОШО
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### Изменения скриптов требуют перезагрузки
После создания или значительного изменения скриптов используйте `reload_project`, чтобы Godot подхватил изменения. Особенно важно после `create_script`.
### Советы по simulate_key
- Используйте **короткие длительности** (0.30.5 секунд) для точного перемещения
- Длинные длительности (1+ секунда) приводят к промахам
- Для тестирования геймплея предпочитайте `simulate_action` вместо `simulate_key`, когда определены действия InputMap
### simulate_mouse_click
- По умолчанию `auto_release: true` отправляет press и release — необходимо для UI-кнопок
- UI-кнопки срабатывают на release, поэтому нужны оба события
### Ограничения execute_game_script
- Нельзя использовать вложенные функции (`func` внутри `func`) — вызывает ошибку компиляции
- Используйте `.get("property")` вместо `.property` для динамического доступа
- Ошибки выполнения приостанавливают отладчик (автоматически продолжается, но лучше избегать)
### Коллизии и области подбора
- Для собираемых предметов используйте Area3D/Area2D с радиусом >= 1.5
- Меньшие радиусы почти невозможно активировать симулированным вводом
### Сохраняйте часто
Вызывайте `save_scene` после значительных изменений. Несохранённые изменения могут быть потеряны при перезагрузке редактора.
## Инструменты анализа и отладки
Когда что-то пошло не так, используйте эти инструменты для расследования:
```
get_editor_errors → проверить ошибки скриптов и исключения runtime
get_output_log → прочитать вывод print() и предупреждения
analyze_scene_complexity → найти узкие места производительности
analyze_signal_flow → визуализировать соединения сигналов
detect_circular_dependencies → найти циклические ссылки скриптов/сцен
find_unused_resources → очистить неиспользуемые файлы
get_performance_monitors → FPS, память, draw calls, статистика физики
```
## Тестирование и QA
```
run_test_scenario → определить и запустить автоматизированные тестовые сценарии
assert_node_state → проверить, что свойства нод соответствуют ожидаемым значениям
assert_screen_text → проверить, что текст отображается на экране
compare_screenshots → визуальное регрессионное тестирование (используйте пути к файлам, не base64)
run_stress_test → создать множество нод для тестирования производительности
```
## Продвинутые паттерны
### Операции между сценами
```
cross_scene_set_property → изменить ноды в сценах, которые сейчас не открыты
find_node_references → найти все файлы, ссылающиеся на паттерн
batch_set_property → установить свойство для всех нод определённого типа
```
### Работа с шейдерами
```
create_shader → написать шейдерный код в стиле GLSL
assign_shader_material → применить к ноде
set_shader_param → настроить uniform-параметры в runtime
get_shader_params → просмотреть текущие значения
```
### Навигация (3D)
```
setup_navigation_region → определить проходимую область
bake_navigation_mesh → сгенерировать навигационную сетку
setup_navigation_agent → добавить поиск пути для персонажей
```
### AnimationTree и конечные автоматы
```
create_animation_tree → настроить AnimationTree с конечным автоматом или деревом смешивания
add_state_machine_state → добавить состояния (idle, walk, run, jump)
add_state_machine_transition → определить переходы между состояниями
set_tree_parameter → управлять параметрами смешивания
```
## Рекомендуемый порядок работы
При создании новой игры с нуля:
1. **Настройка проекта**`get_project_info`, `set_project_setting` (viewport, физика)
2. **Маппинг ввода**`set_input_action` для всех управлений игрока
3. **Главная сцена**`create_scene`, установить как главную сцену
4. **Игрок** — создать сцену игрока со спрайтом, коллизией, скриптом
5. **Уровень/Мир** — построить окружение (TileMap, 3D-меши и т.д.)
6. **Игровая логика** — скрипты для врагов, предметов, UI
7. **Аудио** — настроить шины, добавить аудиоплееры
8. **Плейтестинг**`play_scene`, тест с симулированным вводом, исправление багов
9. **Полировка** — анимации, частицы, шейдеры, темы
10. **Экспорт**`list_export_presets`, `export_project`

View File

@@ -0,0 +1,271 @@
> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | 简体中文 | [हिन्दी](skills.hi.md)
# Godot MCP Pro — AI 助手技能指南
> 将此文件复制到 Godot 项目根目录的 `.claude/skills.md`,以便 Claude Code 获得如何高效使用 Godot MCP Pro 的完整上下文。
## 什么是 Godot MCP Pro
你可以使用 169 个 MCP 工具直接连接 Godot 4 编辑器。你可以创建场景、编写脚本、模拟玩家输入、检查运行中的游戏等等——所有操作都无需用户离开当前对话。每次更改都通过 Godot 的 UndoRedo 系统进行,因此用户随时可以 Ctrl+Z 撤销。
## 核心工作流
### 1. 探索项目
在进行更改之前,先了解项目全貌:
```
get_project_info → 项目名称、Godot 版本、渲染器、视口大小
get_filesystem_tree → 目录结构(可使用 filter: "*.tscn" 或 "*.gd"
get_scene_tree → 当前打开场景的节点层级
read_script → 读取任意 GDScript 文件
get_project_settings → 检查项目配置
```
### 2. 构建 2D 场景
```
create_scene → 创建 .tscn 文件并指定根节点类型
add_node → 添加带属性的子节点
create_script → 编写游戏逻辑的 GDScript
attach_script → 将脚本附加到节点
update_property → 设置 position、scale、modulate 等
save_scene → 保存到磁盘
```
**示例——创建玩家:**
1. `create_scene` 设置 root_type 为 `CharacterBody2D`path 为 `res://scenes/player.tscn`
2. `add_node` 类型 `Sprite2D` 并设置 texture 属性
3. `add_node` 类型 `CollisionShape2D`
4. `add_resource` 为 CollisionShape2D 分配形状(如 `RectangleShape2D`
5. `create_script` 编写移动逻辑
6. `attach_script` 附加到根节点
7. `save_scene`
### 3. 构建 3D 场景
```
create_scene → root_type: Node3D
add_mesh_instance → 添加基础体box、sphere、cylinder、plane或导入 .glb/.gltf
setup_lighting → 添加 DirectionalLight3D、OmniLight3D 或 SpotLight3D
setup_environment → 天空、环境光、雾、色调映射
setup_camera_3d → 摄像机(可选 SpringArm3D 实现第三人称视角)
set_material_3d → PBR 材质albedo、metallic、roughness、emission
setup_collision → 为物理体添加碰撞形状
setup_physics_body → 配置质量、摩擦力、重力
```
### 4. 编写和编辑脚本
```
create_script → 创建新的 .gd 文件(提供完整内容)
edit_script → 修改现有脚本
- 使用 `replacements: [{search: "old code", replace: "new code"}]` 进行定向编辑
- 使用 `content` 完整替换文件
- 使用 `insert_at_line` + `text` 插入代码
validate_script → 不运行即可检查语法错误
read_script → 编辑前读取当前内容
```
### 5. 测试与调试
```
play_scene → 启动游戏mode: "current"、"main" 或文件路径)
get_game_screenshot → 查看游戏当前画面
capture_frames → 捕获多帧以观察运动/动画
get_game_scene_tree → 检查运行时的场景树
get_game_node_properties → 读取运行时数值position、health、state 等)
set_game_node_property → 修改运行中游戏的数值
simulate_key → 按键WASD、SPACE 等)并指定持续时间
simulate_mouse_click → 在视口坐标处点击
simulate_action → 触发 InputMap 动作move_left、jump 等)
get_editor_errors → 检查运行时错误
stop_scene → 停止游戏
```
**测试循环:**
1. `play_scene` → 启动游戏
2. `get_game_screenshot` → 查看当前状态
3. `simulate_key` / `simulate_action` → 与游戏交互
4. `capture_frames` → 观察一段时间内的行为
5. `get_game_node_properties` → 检查特定数值
6. `stop_scene` → 完成后停止
7. 修复脚本问题 → 重复
### 6. 动画
```
# 确保场景中存在 AnimationPlayer 节点
create_animation → 创建带时长和循环模式的新动画
add_animation_track → 添加 property/transform/method 轨道
set_animation_keyframe → 在特定时间插入关键帧
get_animation_info → 查看现有动画信息
```
**示例——弹跳精灵:**
1. `create_animation` name 为 `bounce`length 为 `1.0`loop_mode 为 `1`(线性循环)
2. `add_animation_track` track_path 为 `Sprite2D:position`track_type 为 `value`
3. `set_animation_keyframe` time 为 `0.0`value 为 `Vector2(0, 0)`
4. `set_animation_keyframe` time 为 `0.5`value 为 `Vector2(0, -50)`
5. `set_animation_keyframe` time 为 `1.0`value 为 `Vector2(0, 0)`
### 7. UI / HUD
```
add_node → Control、Label、Button、TextureRect 等
set_anchor_preset → 定位 Controlfull_rect、center、bottom_wide 等)
set_theme_color → 修改 font_color 等
set_theme_font_size → 调整文字大小
set_theme_stylebox → 背景、边框、圆角
connect_signal → 连接 button 的 pressed、value_changed 等信号
```
### 8. TileMap
```
tilemap_get_info → 检查图块集来源和图集布局
tilemap_set_cell → 放置单个图块
tilemap_fill_rect → 填充矩形区域
tilemap_get_used_cells → 查看已放置的内容
tilemap_clear → 清除所有单元格
```
### 9. 音频
```
add_audio_bus → 创建音频总线SFX、Music、UI
set_audio_bus → 调整音量、独奏、静音
add_audio_bus_effect → 添加混响、延迟、压缩器等
add_audio_player → 添加 AudioStreamPlayer(2D/3D) 节点
```
### 10. 项目配置
```
set_project_setting → 修改视口大小、物理设置等
set_input_action → 定义输入映射move_left → KEY_A 等)
add_autoload → 注册 autoload 单例
set_physics_layers → 命名碰撞层player、enemy、world 等)
```
## 重要规则与注意事项
### 属性值
属性会从字符串自动解析。使用以下格式:
- Vector2: `"Vector2(100, 200)"`
- Vector3: `"Vector3(1, 2, 3)"`
- Color: `"Color(1, 0, 0, 1)"``"#ff0000"`
- Bool: `"true"` / `"false"`
- 数字: `"42"``"3.14"`
- 枚举: 使用整数值(例如第一个枚举值用 `0`
### 不要直接编辑 project.godot
Godot 编辑器会不断覆盖 `project.godot`。修改项目设置请务必使用 `set_project_setting`
### GDScript 类型注解
在对无类型数组使用 `for` 循环时,请使用显式类型注解:
```gdscript
# 错误——会导致报错
for item in some_untyped_array:
var x := item.value # 类型推断失败
# 正确
for i in range(some_untyped_array.size()):
var item: Dictionary = some_untyped_array[i]
var x: int = item.value
```
### 脚本更改需要重新加载
创建或大幅修改脚本后,使用 `reload_project` 确保 Godot 识别更改。在 `create_script` 之后尤其重要。
### simulate_key 技巧
- 精确移动使用**短持续时间**0.3-0.5 秒)
- 长持续时间1 秒以上)会导致过冲
- 游戏测试时,如果已定义 InputMap 动作,优先使用 `simulate_action` 而非 `simulate_key`
### simulate_mouse_click
- 默认 `auto_release: true` 会同时发送按下和释放事件——UI 按钮必须如此
- UI 按钮在释放时触发,因此两个事件都必不可少
### execute_game_script 限制
- 不支持嵌套函数(`func` 中的 `func`)——会导致编译错误
- 动态访问请使用 `.get("property")` 而非 `.property`
- 运行时错误会暂停调试器(会自动继续,但尽量避免)
### 碰撞与拾取区域
- 可收集物品请使用 Area3D/Area2D 并设置半径 >= 1.5
- 较小的半径几乎无法通过模拟输入触发
### 经常保存
进行重大更改后请调用 `save_scene`。未保存的更改可能在编辑器重新加载时丢失。
## 分析与调试工具
出现问题时,使用以下工具进行排查:
```
get_editor_errors → 检查脚本错误和运行时异常
get_output_log → 读取 print() 输出和警告
analyze_scene_complexity → 查找性能瓶颈
analyze_signal_flow → 可视化信号连接
detect_circular_dependencies → 查找脚本/场景的循环引用
find_unused_resources → 清理未使用的文件
get_performance_monitors → FPS、内存、绘制调用、物理统计
```
## 测试与 QA
```
run_test_scenario → 定义并运行自动化测试序列
assert_node_state → 验证节点属性是否匹配预期值
assert_screen_text → 验证文本是否显示在屏幕上
compare_screenshots → 视觉回归测试(使用文件路径,不要用 base64
run_stress_test → 生成大量节点以测试性能
```
## 高级模式
### 跨场景操作
```
cross_scene_set_property → 修改当前未打开场景中的节点
find_node_references → 查找引用某个模式的所有文件
batch_set_property → 为某类型的所有节点设置属性
```
### 着色器工作流
```
create_shader → 编写类 GLSL 的着色器代码
assign_shader_material → 应用到节点
set_shader_param → 在运行时调整 uniform 参数
get_shader_params → 查看当前值
```
### 导航3D
```
setup_navigation_region → 定义可行走区域
bake_navigation_mesh → 生成导航网格
setup_navigation_agent → 为角色添加寻路功能
```
### AnimationTree 与状态机
```
create_animation_tree → 使用状态机或混合树设置 AnimationTree
add_state_machine_state → 添加状态idle、walk、run、jump
add_state_machine_transition → 定义状态之间的过渡
set_tree_parameter → 控制混合参数
```
## 推荐工作流顺序
从零开始构建新游戏时:
1. **项目设置**`get_project_info``set_project_setting`(视口、物理)
2. **输入映射**`set_input_action` 定义所有玩家控制
3. **主场景**`create_scene`,设为主场景
4. **玩家** — 创建包含精灵、碰撞、脚本的玩家场景
5. **关卡/世界** — 构建环境TileMap、3D 网格等)
6. **游戏逻辑** — 敌人、道具、UI 的脚本
7. **音频** — 设置总线、添加音频播放器
8. **测试**`play_scene`,使用模拟输入测试,修复 bug
9. **打磨** — 动画、粒子、着色器、主题
10. **导出**`list_export_presets``export_project`

View File

@@ -0,0 +1,382 @@
@tool
extends VBoxContainer
var websocket_server: Node = null
var command_router: Node = null
const MAX_LOG_ENTRIES := 200
const COLOR_CONNECTED := Color(0.2, 0.9, 0.2)
const COLOR_DISCONNECTED := Color(0.9, 0.2, 0.2)
const COLOR_STALE := Color(1.0, 0.7, 0.2)
const COLOR_SUCCESS := Color(0.6, 1, 0.6)
const COLOR_ERROR := Color(1, 0.6, 0.6)
const COLOR_DIM := Color(0.6, 0.6, 0.6)
const BASE_PORT := 6505
const MAX_PORT := 6509
# Header
var _status_icon: Label
var _status_label: Label
var _client_count_label: Label
# Tabs
var _tab_container: TabContainer
# Activity tab
var _show_details_check: CheckBox
var _log_container: VBoxContainer
var _log_scroll: ScrollContainer
# Clients tab
var _port_labels: Dictionary = {} # port -> {icon: Label, label: Label}
# Tools tab
var _filter_edit: LineEdit
var _tools_container: VBoxContainer
var _tool_checkboxes: Dictionary = {} # method_name -> CheckBox
func _ready() -> void:
_build_ui()
func setup(ws_server: Node, cmd_router: Node = null) -> void:
websocket_server = ws_server
command_router = cmd_router
if websocket_server:
websocket_server.client_connected.connect(_on_client_connected)
websocket_server.client_disconnected.connect(_on_client_disconnected)
if websocket_server.has_signal("command_completed"):
websocket_server.command_completed.connect(_on_command_completed)
else:
websocket_server.command_executed.connect(_on_command_executed)
if command_router:
_populate_tools_list()
func _build_ui() -> void:
# Header bar
var header := HBoxContainer.new()
add_child(header)
_status_icon = Label.new()
_status_icon.text = ""
_status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
header.add_child(_status_icon)
_status_label = Label.new()
_status_label.text = " MCP Pro: Waiting for connection..."
header.add_child(_status_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
_client_count_label = Label.new()
_client_count_label.text = "Clients: 0"
header.add_child(_client_count_label)
# Separator
var sep := HSeparator.new()
add_child(sep)
# TabContainer
_tab_container = TabContainer.new()
_tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(_tab_container)
_build_activity_tab()
_build_clients_tab()
_build_tools_tab()
func _build_activity_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Activity"
vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
_tab_container.add_child(vbox)
# Controls row
var controls := HBoxContainer.new()
vbox.add_child(controls)
_show_details_check = CheckBox.new()
_show_details_check.text = "Show Response Details"
_show_details_check.button_pressed = false
_show_details_check.toggled.connect(_on_show_details_toggled)
controls.add_child(_show_details_check)
var ctrl_spacer := Control.new()
ctrl_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
controls.add_child(ctrl_spacer)
var clear_btn := Button.new()
clear_btn.text = "Clear"
clear_btn.pressed.connect(_on_clear_log)
controls.add_child(clear_btn)
# Log scroll
_log_scroll = ScrollContainer.new()
_log_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_log_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_log_scroll.custom_minimum_size.y = 80
vbox.add_child(_log_scroll)
_log_container = VBoxContainer.new()
_log_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_log_scroll.add_child(_log_container)
func _build_clients_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Clients"
_tab_container.add_child(vbox)
for p in range(BASE_PORT, MAX_PORT + 1):
var row := HBoxContainer.new()
vbox.add_child(row)
var icon := Label.new()
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
row.add_child(icon)
var lbl := Label.new()
lbl.text = " Port %d — Disconnected" % p
row.add_child(lbl)
_port_labels[p] = {"icon": icon, "label": lbl}
func _build_tools_tab() -> void:
var vbox := VBoxContainer.new()
vbox.name = "Tools"
vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
_tab_container.add_child(vbox)
# Controls
var controls := HBoxContainer.new()
vbox.add_child(controls)
_filter_edit = LineEdit.new()
_filter_edit.placeholder_text = "Filter tools..."
_filter_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_filter_edit.text_changed.connect(_on_filter_changed)
controls.add_child(_filter_edit)
var enable_all_btn := Button.new()
enable_all_btn.text = "Enable All"
enable_all_btn.pressed.connect(_on_enable_all)
controls.add_child(enable_all_btn)
var disable_all_btn := Button.new()
disable_all_btn.text = "Disable All"
disable_all_btn.pressed.connect(_on_disable_all)
controls.add_child(disable_all_btn)
# Scroll
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size.y = 80
vbox.add_child(scroll)
_tools_container = VBoxContainer.new()
_tools_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(_tools_container)
func _populate_tools_list() -> void:
if not command_router:
return
# Clear existing
for child in _tools_container.get_children():
child.queue_free()
_tool_checkboxes.clear()
var methods: Array = command_router.get_available_methods()
methods.sort()
for method_name: String in methods:
var cb := CheckBox.new()
cb.text = method_name
cb.button_pressed = not command_router.is_tool_disabled(method_name)
cb.toggled.connect(_on_tool_toggled.bind(method_name))
_tools_container.add_child(cb)
_tool_checkboxes[method_name] = cb
func _process(_delta: float) -> void:
if not websocket_server:
return
var count: int = websocket_server.get_client_count()
_client_count_label.text = "Clients: %d" % count
var any_stale := false
if websocket_server.has_method("is_port_stale"):
for p in range(BASE_PORT, MAX_PORT + 1):
if websocket_server.is_port_stale(p):
any_stale = true
break
if count > 0:
_status_icon.add_theme_color_override("font_color", COLOR_CONNECTED)
_status_label.text = " MCP Pro: Connected"
elif any_stale:
_status_icon.add_theme_color_override("font_color", COLOR_STALE)
_status_label.text = " MCP Pro: ⚠ Reconnecting (stale connection detected)..."
else:
_status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
_status_label.text = " MCP Pro: Waiting for connection..."
# Update clients tab
_update_clients_tab()
func _update_clients_tab() -> void:
var connected_ports: Array[int] = []
if websocket_server.has_method("get_connected_ports"):
connected_ports = websocket_server.get_connected_ports()
for p: int in _port_labels:
var info: Dictionary = _port_labels[p]
var icon: Label = info["icon"]
var lbl: Label = info["label"]
var is_stale := false
if websocket_server.has_method("is_port_stale"):
is_stale = websocket_server.is_port_stale(p)
if p in connected_ports:
var time_str := ""
if websocket_server.has_method("get_port_connect_time"):
var elapsed: float = websocket_server.get_port_connect_time(p)
if elapsed >= 0:
var mins := int(elapsed) / 60
var secs := int(elapsed) % 60
time_str = " (%dm %02ds)" % [mins, secs]
var idle_str := ""
if websocket_server.has_method("get_port_idle_time"):
var idle: float = websocket_server.get_port_idle_time(p)
if idle >= 2.0:
idle_str = " · idle %.0fs" % idle
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_CONNECTED)
lbl.text = " Port %d — Connected%s%s" % [p, time_str, idle_str]
elif is_stale:
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_STALE)
lbl.text = " Port %d — ⚠ Stale (reconnecting)" % p
else:
icon.text = ""
icon.add_theme_color_override("font_color", COLOR_DISCONNECTED)
lbl.text = " Port %d — Disconnected" % p
# --- Activity callbacks ---
func _on_client_connected() -> void:
_add_log("Client connected", COLOR_CONNECTED)
func _on_client_disconnected() -> void:
_add_log("Client disconnected", COLOR_DISCONNECTED)
func _on_command_executed(method: String, ok: bool) -> void:
var color := COLOR_SUCCESS if ok else COLOR_ERROR
var status_icon := "OK" if ok else "ERR"
_add_log("[%s] %s" % [status_icon, method], color)
func _on_command_completed(method: String, ok: bool, response: String, source_port: int) -> void:
var color := COLOR_SUCCESS if ok else COLOR_ERROR
var status_icon := "OK" if ok else "ERR"
_add_log("[%s] %s (port %d)" % [status_icon, method, source_port], color, response)
func _on_clear_log() -> void:
for child in _log_container.get_children():
child.queue_free()
func _on_show_details_toggled(on: bool) -> void:
for entry in _log_container.get_children():
if entry is VBoxContainer and entry.get_child_count() > 1:
entry.get_child(1).visible = on
func _add_log(text: String, color: Color = Color.WHITE, response: String = "") -> void:
if _log_container == null:
return
var entry := VBoxContainer.new()
_log_container.add_child(entry)
var label := Label.new()
var time_str := Time.get_time_string_from_system()
label.text = "[%s] %s" % [time_str, text]
label.add_theme_color_override("font_color", color)
label.add_theme_font_size_override("font_size", 12)
entry.add_child(label)
if not response.is_empty():
var detail := RichTextLabel.new()
var preview := response.substr(0, 500)
if response.length() > 500:
preview += "..."
detail.text = preview
detail.fit_content = true
detail.scroll_active = false
detail.add_theme_color_override("default_color", COLOR_DIM)
detail.add_theme_font_size_override("normal_font_size", 11)
detail.custom_minimum_size.y = 0
detail.visible = _show_details_check.button_pressed if _show_details_check else false
entry.add_child(detail)
# Limit entries
while _log_container.get_child_count() > MAX_LOG_ENTRIES:
var old: Node = _log_container.get_child(0)
_log_container.remove_child(old)
old.queue_free()
# Auto scroll to bottom
_auto_scroll.call_deferred()
func _auto_scroll() -> void:
if _log_scroll:
_log_scroll.scroll_vertical = int(_log_scroll.get_v_scroll_bar().max_value)
# --- Tools callbacks ---
func _on_filter_changed(filter: String) -> void:
for method_name: String in _tool_checkboxes:
var cb: CheckBox = _tool_checkboxes[method_name]
cb.visible = filter.is_empty() or method_name.containsn(filter)
func _on_tool_toggled(enabled: bool, method_name: String) -> void:
if command_router and command_router.has_method("set_tool_disabled"):
command_router.set_tool_disabled(method_name, not enabled)
func _on_enable_all() -> void:
if command_router and command_router.has_method("set_all_tools_disabled"):
command_router.set_all_tools_disabled(false)
for method_name: String in _tool_checkboxes:
_tool_checkboxes[method_name].set_pressed_no_signal(true)
func _on_disable_all() -> void:
if command_router and command_router.has_method("set_all_tools_disabled"):
command_router.set_all_tools_disabled(true)
for method_name: String in _tool_checkboxes:
_tool_checkboxes[method_name].set_pressed_no_signal(false)

View File

@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"]
[node name="MCPStatusPanel" type="VBoxContainer"]
offset_right = 600.0
offset_bottom = 300.0
script = ExtResource("1")

View File

@@ -0,0 +1,76 @@
@tool
extends RefCounted
## Recursively set owner for all children (needed when adding nodes via code)
static func set_owner_recursive(node: Node, owner: Node) -> void:
for child in node.get_children():
child.owner = owner
set_owner_recursive(child, owner)
## Get a simplified tree structure from a node
static func get_node_tree(node: Node, max_depth: int = -1, current_depth: int = 0) -> Dictionary:
var result := {
"name": node.name,
"type": node.get_class(),
"path": str(node.get_path()),
}
# Add script info
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
# Add children
if max_depth == -1 or current_depth < max_depth:
var children: Array = []
for child in node.get_children():
children.append(get_node_tree(child, max_depth, current_depth + 1))
if not children.is_empty():
result["children"] = children
return result
## Get all properties of a node as a serializable dictionary
static func get_node_properties_dict(node: Node) -> Dictionary:
var PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
var result: Dictionary = {}
var property_list := node.get_property_list()
for prop_info in property_list:
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
# Only include user-facing properties (PROPERTY_USAGE_EDITOR)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
# Skip internal/meta properties
if prop_name.begins_with("_") or prop_name in ["script"]:
continue
var value: Variant = node.get(prop_name)
result[prop_name] = PropertyParser.serialize_value(value)
return result
## Duplicate a node and all its children, properly setting owners
static func duplicate_node_in_scene(node: Node, new_name: String, root: Node) -> Node:
var dup := node.duplicate()
dup.name = new_name
node.get_parent().add_child(dup)
dup.owner = root
set_owner_recursive(dup, root)
return dup
## Find node by class type in subtree
static func find_nodes_by_type(root: Node, type_name: String) -> Array[Node]:
var result: Array[Node] = []
if root.get_class() == type_name or root.is_class(type_name):
result.append(root)
for child in root.get_children():
result.append_array(find_nodes_by_type(child, type_name))
return result

View File

@@ -0,0 +1,203 @@
@tool
extends RefCounted
## Parse a string value into the appropriate Godot type
static func parse_value(value: Variant, target_type: int = TYPE_NIL) -> Variant:
if value == null:
return null
# If already the correct type, return as-is
if target_type == TYPE_NIL:
return _auto_parse(value)
match target_type:
TYPE_BOOL:
if value is bool: return value
if value is String: return value.to_lower() in ["true", "1", "yes"]
return bool(value)
TYPE_INT:
return int(value)
TYPE_FLOAT:
return float(value)
TYPE_STRING:
return str(value)
TYPE_VECTOR2:
return _parse_vector2(value)
TYPE_VECTOR2I:
return _parse_vector2i(value)
TYPE_VECTOR3:
return _parse_vector3(value)
TYPE_VECTOR3I:
return _parse_vector3i(value)
TYPE_RECT2:
return _parse_rect2(value)
TYPE_COLOR:
return _parse_color(value)
TYPE_NODE_PATH:
return NodePath(str(value))
TYPE_ARRAY:
if value is Array: return value
return [value]
TYPE_DICTIONARY:
if value is Dictionary: return value
return {}
_:
return value
static func _auto_parse(value: Variant) -> Variant:
if not value is String:
return value
var s: String = value
# Boolean
if s == "true": return true
if s == "false": return false
# Integer
if s.is_valid_int(): return s.to_int()
# Float
if s.is_valid_float(): return s.to_float()
# Vector2: "Vector2(x, y)" or "(x, y)" or "x, y"
if s.begins_with("Vector2(") or s.begins_with("Vector2i("):
return _parse_vector2(s)
# Vector3
if s.begins_with("Vector3(") or s.begins_with("Vector3i("):
return _parse_vector3(s)
# Color
if s.begins_with("Color(") or s.begins_with("#"):
return _parse_color(s)
# Rect2
if s.begins_with("Rect2("):
return _parse_rect2(s)
return s
static func _extract_numbers(s: String) -> PackedFloat64Array:
# Remove type prefix and parentheses
var cleaned := s
for prefix in ["Vector3i(", "Vector3(", "Vector2i(", "Vector2(", "Rect2(", "Color(", "("]:
if cleaned.begins_with(prefix):
cleaned = cleaned.substr(prefix.length())
break
cleaned = cleaned.trim_suffix(")")
cleaned = cleaned.strip_edges()
var parts := cleaned.split(",")
var numbers: PackedFloat64Array = []
for part in parts:
numbers.append(part.strip_edges().to_float())
return numbers
static func _parse_vector2(value: Variant) -> Vector2:
if value is Vector2: return value
if value is Dictionary:
return Vector2(float(value.get("x", 0)), float(value.get("y", 0)))
var nums := _extract_numbers(str(value))
if nums.size() >= 2:
return Vector2(nums[0], nums[1])
return Vector2.ZERO
static func _parse_vector2i(value: Variant) -> Vector2i:
var v := _parse_vector2(value)
return Vector2i(int(v.x), int(v.y))
static func _parse_vector3(value: Variant) -> Vector3:
if value is Vector3: return value
if value is Dictionary:
return Vector3(float(value.get("x", 0)), float(value.get("y", 0)), float(value.get("z", 0)))
var nums := _extract_numbers(str(value))
if nums.size() >= 3:
return Vector3(nums[0], nums[1], nums[2])
return Vector3.ZERO
static func _parse_vector3i(value: Variant) -> Vector3i:
var v := _parse_vector3(value)
return Vector3i(int(v.x), int(v.y), int(v.z))
static func _parse_rect2(value: Variant) -> Rect2:
if value is Rect2: return value
if value is Dictionary:
return Rect2(
float(value.get("x", 0)), float(value.get("y", 0)),
float(value.get("w", value.get("width", 0))),
float(value.get("h", value.get("height", 0)))
)
var nums := _extract_numbers(str(value))
if nums.size() >= 4:
return Rect2(nums[0], nums[1], nums[2], nums[3])
return Rect2()
static func _parse_color(value: Variant) -> Color:
if value is Color: return value
var s := str(value)
if s.begins_with("#"):
return Color.html(s)
if s.begins_with("Color("):
var nums := _extract_numbers(s)
match nums.size():
3: return Color(nums[0], nums[1], nums[2])
4: return Color(nums[0], nums[1], nums[2], nums[3])
# Try named color
if Color.html_is_valid(s):
return Color.html(s)
return Color.WHITE
## Serialize a Variant to JSON-safe representation
static func serialize_value(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_VECTOR2:
var v: Vector2 = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR2I:
var v: Vector2i = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR3:
var v: Vector3 = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_VECTOR3I:
var v: Vector3i = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_RECT2:
var r: Rect2 = value
return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y}
TYPE_COLOR:
var c: Color = value
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()}
TYPE_NODE_PATH:
return str(value)
TYPE_OBJECT:
if value is Resource:
var res: Resource = value
return {"type": res.get_class(), "path": res.resource_path}
return str(value)
TYPE_ARRAY:
var arr: Array = value
var result: Array = []
for item in arr:
result.append(serialize_value(item))
return result
TYPE_DICTIONARY:
var dict: Dictionary = value
var result: Dictionary = {}
for key in dict:
result[str(key)] = serialize_value(dict[key])
return result
_:
return value

View File

@@ -0,0 +1,254 @@
@tool
extends Node
## Multi-connection WebSocket client.
## Connects to multiple Node.js MCP server instances on ports 6505-6514.
## Each Claude Code session gets its own port; Godot talks to all of them.
## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections.
signal client_connected()
signal client_disconnected()
signal message_received(text: String)
signal command_executed(method: String, success: bool)
signal command_completed(method: String, success: bool, response: String, source_port: int)
var command_router: Node
const BASE_PORT := 6505
const MAX_PORT := 6514
const RECONNECT_INTERVAL := 3.0
const BUFFER_SIZE := 16 * 1024 * 1024 # 16MB
const PING_INTERVAL := 5.0 # send ping every N seconds while connected
const INACTIVITY_TIMEOUT := 30.0 # force-close if no message received for N seconds
# Per-port connection state
var _peers: Dictionary = {} # port -> WebSocketPeer
var _connected: Dictionary = {} # port -> bool
var _timers: Dictionary = {} # port -> float (reconnect countdown)
var _connect_times: Dictionary = {} # port -> float (elapsed seconds since connect)
var _last_activity: Dictionary = {} # port -> float (seconds since last received message)
var _ping_timers: Dictionary = {} # port -> float (seconds since last sent ping)
var _stale_ports: Dictionary = {} # port -> bool (heartbeat timeout flag, exposed to UI)
var _running: bool = false
func start_server() -> void:
_running = true
for p in range(BASE_PORT, MAX_PORT + 1):
_connected[p] = false
_timers[p] = 0.0
_try_connect(p)
print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT])
func stop_server() -> void:
_running = false
for p in _peers:
var ws: WebSocketPeer = _peers[p]
if ws:
ws.close(1000, "Plugin shutting down")
_peers.clear()
_connected.clear()
_timers.clear()
_last_activity.clear()
_ping_timers.clear()
_stale_ports.clear()
print("[MCP] WebSocket client stopped")
func get_client_count() -> int:
var count: int = 0
for p in _connected:
if _connected[p]:
count += 1
return count
func get_connected_ports() -> Array[int]:
var ports: Array[int] = []
for p: int in _connected:
if _connected[p]:
ports.append(p)
return ports
func get_port_connect_time(port: int) -> float:
return _connect_times.get(port, -1.0)
func get_port_idle_time(port: int) -> float:
return _last_activity.get(port, -1.0)
func is_port_stale(port: int) -> bool:
return _stale_ports.get(port, false)
func _try_connect(p: int) -> void:
var ws := WebSocketPeer.new()
ws.outbound_buffer_size = BUFFER_SIZE
ws.inbound_buffer_size = BUFFER_SIZE
var err := ws.connect_to_url("ws://127.0.0.1:%d" % p)
if err == OK:
_peers[p] = ws
else:
_peers[p] = null
func _process(delta: float) -> void:
if not _running:
return
for p in range(BASE_PORT, MAX_PORT + 1):
var ws: WebSocketPeer = _peers.get(p)
# No peer - try reconnect on timer
if ws == null:
_timers[p] = _timers.get(p, 0.0) + delta
if _timers[p] >= RECONNECT_INTERVAL:
_timers[p] = 0.0
_try_connect(p)
continue
ws.poll()
var state := ws.get_ready_state()
match state:
WebSocketPeer.STATE_OPEN:
if not _connected.get(p, false):
_connected[p] = true
_connect_times[p] = 0.0
_last_activity[p] = 0.0
_ping_timers[p] = 0.0
_stale_ports[p] = false
_timers[p] = 0.0
print_verbose("[MCP] Connected on port %d" % p)
client_connected.emit()
else:
_connect_times[p] = _connect_times.get(p, 0.0) + delta
_last_activity[p] = _last_activity.get(p, 0.0) + delta
_ping_timers[p] = _ping_timers.get(p, 0.0) + delta
var received_any := false
while ws.get_available_packet_count() > 0:
var packet := ws.get_packet()
var text := packet.get_string_from_utf8()
received_any = true
_dispatch_message(text, p)
if received_any:
_last_activity[p] = 0.0
if _stale_ports.get(p, false):
_stale_ports[p] = false
print("[MCP] Port %d recovered from stale state" % p)
# Force-close if no message received for INACTIVITY_TIMEOUT.
# The MCP server pings every 10s, so 30s of silence means the
# connection is half-open and reconnect is the only way out.
if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT:
push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]])
_stale_ports[p] = true
ws.close(4000, "Heartbeat timeout")
_connected[p] = false
_peers[p] = null
_timers[p] = 0.0
client_disconnected.emit()
continue
# Send periodic ping so the server can detect our death too,
# and so any reply resets our own inactivity timer.
if _ping_timers.get(p, 0.0) >= PING_INTERVAL:
_ping_timers[p] = 0.0
ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}}))
WebSocketPeer.STATE_CLOSING:
pass
WebSocketPeer.STATE_CLOSED:
if _connected.get(p, false):
_connected[p] = false
print_verbose("[MCP] Disconnected from port %d" % p)
client_disconnected.emit()
_peers[p] = null
_timers[p] = 0.0
_last_activity[p] = 0.0
_ping_timers[p] = 0.0
WebSocketPeer.STATE_CONNECTING:
pass
func _send_to_port(p: int, text: String) -> void:
var ws: WebSocketPeer = _peers.get(p)
if ws and _connected.get(p, false):
ws.send_text(text)
func send_message(text: String) -> void:
# Broadcast to all connected peers
for p in _peers:
_send_to_port(p, text)
## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution
func _dispatch_message(text: String, source_port: int) -> void:
message_received.emit(text)
var json := JSON.new()
var err := json.parse(text)
if err != OK:
_send_response(source_port, null, null, {"code": -32700, "message": "Parse error"})
return
var msg: Variant = json.data
if not msg is Dictionary:
_send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"})
return
var msg_dict: Dictionary = msg
if msg_dict.get("method") == "ping":
_send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}}))
return
if msg_dict.get("method") == "pong":
return
var id: Variant = msg_dict.get("id")
var method: String = msg_dict.get("method", "")
var params: Dictionary = msg_dict.get("params", {})
if method.is_empty():
_send_response(source_port, id, null, {"code": -32600, "message": "Missing method"})
return
if not command_router:
_send_response(source_port, id, null, {"code": -32603, "message": "No command router"})
return
_execute_command.call_deferred(source_port, id, method, params)
func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void:
var cmd_result: Dictionary = await command_router.execute(method, params)
if cmd_result.has("error"):
var err_data: Variant = cmd_result["error"]
_send_response(source_port, id, null, err_data)
var response_text := JSON.stringify(err_data)
command_executed.emit(method, false)
command_completed.emit(method, false, response_text, source_port)
else:
var result_data: Variant = cmd_result.get("result", {})
_send_response(source_port, id, result_data, null)
var response_text := JSON.stringify(result_data)
command_executed.emit(method, true)
command_completed.emit(method, true, response_text, source_port)
func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void:
var response: Dictionary = {"jsonrpc": "2.0", "id": id}
if err != null:
response["error"] = err
else:
response["result"] = result if result != null else {}
_send_to_port(source_port, JSON.stringify(response))

View File

@@ -0,0 +1,70 @@
# Architecture
## Overview
```
┌─────────────┐ stdio/MCP ┌──────────────┐ WebSocket:6505 ┌──────────────────┐
│ AI Client │ ←────────────────→ │ Node.js MCP │ ←──────────────────→ │ Godot Plugin │
│ (Claude Code)│ │ Server │ JSON-RPC 2.0 │ (Editor Plugin) │
└─────────────┘ └──────────────┘ └──────────────────┘
```
## Communication Flow
1. AI client sends MCP tool call (e.g. `add_node`)
2. Node.js server translates to JSON-RPC 2.0 request
3. WebSocket sends to Godot plugin
4. Plugin's command router dispatches to handler
5. Handler executes via Godot Editor API (with UndoRedo)
6. Result sent back as JSON-RPC 2.0 response
7. Node.js formats as MCP tool result
8. AI receives structured response
## Godot Plugin Structure
```
plugin.gd (EditorPlugin)
├── websocket_server.gd (TCP+WebSocket server)
├── command_router.gd (dispatch hub)
│ ├── project_commands.gd (6 commands)
│ ├── scene_commands.gd (8 commands)
│ ├── node_commands.gd (8 commands)
│ ├── script_commands.gd (6 commands)
│ └── editor_commands.gd (5 commands)
└── ui/status_panel (connection monitor)
```
## Key Design Decisions
### WebSocket over HTTP
- Real-time bidirectional communication
- Natural for editor integration (persistent connection)
- Heartbeat keeps connection alive
### JSON-RPC 2.0
- Standard protocol with well-defined error codes
- Each request has unique ID for tracking
- Easy to debug and extend
### UndoRedo Integration
- All scene modifications go through `EditorUndoRedoManager`
- Users can Ctrl+Z any AI-made change
- Prevents accidental data loss
### Type Parsing
- `PropertyParser` handles string → Godot type conversion
- Supports Vector2/3, Color, Rect2, NodePath, etc.
- AI can send simple strings, plugin handles the rest
## Error Codes
| Code | Meaning |
|------|---------|
| -32700 | Parse error (invalid JSON) |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32000 | No scene open |
| -32001 | Node/resource not found |
| -32002 | Script compilation failed |

View File

@@ -0,0 +1,73 @@
# Installation Guide
## Prerequisites
- Godot 4.3+ (tested with 4.6)
- Node.js 18+
- An MCP-compatible AI client (Claude Code, Claude Desktop, etc.)
## Step 1: Godot Plugin
1. Copy the `addons/godot_mcp/` folder into your Godot project
2. Open Project → Project Settings → Plugins
3. Find "Godot MCP Pro" and click Enable
4. You should see "MCP Server" appear in the bottom panel
5. The status should show "Waiting for connection..."
## Step 2: Node.js Server
```bash
cd server
npm install
npm run build
```
This creates the compiled server in `server/build/`.
## Step 3: MCP Client Configuration
### Claude Code (.mcp.json)
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["/absolute/path/to/godot-mcp-pro/server/build/index.js"]
}
}
}
```
### Custom Port
Set the `GODOT_MCP_PORT` environment variable (default: 6505):
```json
{
"mcpServers": {
"godot-mcp-pro": {
"command": "node",
"args": ["/absolute/path/to/server/build/index.js"],
"env": { "GODOT_MCP_PORT": "6510" }
}
}
}
```
Also update the port in `plugin.gd` (line 3: `const PORT := 6505`).
## Troubleshooting
### Plugin doesn't appear
- Make sure the `addons/godot_mcp/` directory is inside your Godot project
- Check that `plugin.cfg` exists in the directory
### Connection fails
- Verify the Godot editor is running with the plugin enabled
- Check the bottom panel "MCP Server" tab for status
- Ensure no firewall is blocking localhost port 6505
### Tools timeout
- Commands have a 30-second timeout
- Large operations (full filesystem scan) may need the `max_depth` parameter

View File

@@ -0,0 +1,406 @@
# Tools Reference
## Project Tools
### get_project_info
Returns project metadata including name, Godot version, viewport settings, renderer, and autoloads.
### get_filesystem_tree
Scans the project directory and returns a tree structure.
- `path` (optional): Root path (default: `res://`)
- `filter` (optional): Glob pattern like `*.gd`, `*.tscn`
- `max_depth` (optional): Maximum recursion depth (default: 10)
### search_files
Fuzzy search for files by name.
- `query` (required): Search string or glob pattern
- `path` (optional): Root path
- `file_type` (optional): Extension filter (`gd`, `tscn`, etc.)
- `max_results` (optional): Limit results (default: 50)
### get_project_settings
Read settings from project.godot.
- `section` (optional): Filter by section prefix (e.g. `display/window`)
- `key` (optional): Get a specific setting
### uid_to_project_path / project_path_to_uid
Convert between UIDs (`uid://...`) and resource paths (`res://...`).
## Scene Tools
### get_scene_tree
Returns the live node hierarchy of the currently edited scene.
- `max_depth` (optional): Limit tree depth
### get_scene_file_content
Reads the raw .tscn file.
- `path` (required): Scene file path
### create_scene
Creates a new scene file.
- `path` (required): Where to save
- `root_type` (optional): Root node type (default: `Node2D`)
- `root_name` (optional): Root node name
### open_scene / delete_scene
Open or delete a scene file by path.
### add_scene_instance
Instance a scene as a child node.
- `scene_path` (required): Scene to instance
- `parent_path` (optional): Parent node (default: root)
- `name` (optional): Instance name
### play_scene / stop_scene
Run or stop scenes. `play_scene` accepts `mode`: `main`, `current`, or a file path.
## Node Tools
### add_node
Add a node to the scene.
- `type` (required): Node class name
- `parent_path` (optional): Parent node path
- `name` (optional): Node name
- `properties` (optional): Dict of property values
### delete_node / duplicate_node / move_node
Modify scene tree structure. All support undo.
### update_property
Set any node property. Values are auto-parsed:
- `"Vector2(100, 200)"` → Vector2
- `"#ff0000"` or `"Color(1, 0, 0)"` → Color
- `"true"` / `"false"` → bool
- Numbers → int/float
### get_node_properties
Get all editor-visible properties of a node.
- `category` (optional): Filter prefix
### add_resource
Create and assign a resource to a node property.
- `resource_type`: Class name (e.g. `RectangleShape2D`)
- `resource_properties` (optional): Properties for the resource
### set_anchor_preset
Set anchor preset on Control nodes. Available presets: `top_left`, `center`, `full_rect`, etc.
### rename_node
Rename a node in the current scene.
- `node_path` (required): Path to the node
- `new_name` (required): New name for the node
### connect_signal
Connect a signal from one node to a method on another node.
- `source_path` (required): Path to the source node (emitter)
- `signal_name` (required): Signal name to connect
- `target_path` (required): Path to the target node (receiver)
- `method_name` (required): Method name on target to call
### disconnect_signal
Disconnect a signal connection between two nodes.
- `source_path` (required): Path to the source node (emitter)
- `signal_name` (required): Signal name to disconnect
- `target_path` (required): Path to the target node (receiver)
- `method_name` (required): Method name on target
## Script Tools
### list_scripts
Find all scripts with class/extends info.
### read_script / create_script
Read or create script files.
### edit_script
Edit scripts via:
1. `replacements`: Array of `{search, replace, regex?}` operations
2. `content`: Full file replacement
3. `insert_at_line` + `text`: Insert at specific line
### attach_script
Attach a script to a node in the current scene.
### get_open_scripts
List scripts currently open in the script editor.
## Editor Tools
### get_editor_errors
Get recent errors from the Godot log.
### get_editor_screenshot / get_game_screenshot
Capture viewport as PNG (returned as base64 image).
### execute_editor_script
Run arbitrary GDScript in the editor context. Use `_mcp_print(value)` to capture output.
### clear_output
Clear the editor output panel.
### get_signals
Get all signals of a node, including current connections.
- `node_path` (required): Path to the node to inspect
### reload_plugin
Reload the Godot MCP Pro plugin (disable/re-enable). Connection will briefly drop and auto-reconnect.
### reload_project
Rescan the Godot project filesystem and reload changed scripts. No reconnection needed.
### save_scene
Save the currently edited scene to disk.
- `path` (optional): Path to save to (defaults to current scene path)
### set_project_setting
Set a project setting value via the editor API.
- `key` (required): Setting key (e.g. `display/window/size/viewport_width`)
- `value` (required): Value to set (auto-parsed for Vector2, bool, int, float)
## Input Tools
### simulate_key
Simulate a keyboard key press/release in the running game.
- `keycode` (required): Key constant (e.g. `KEY_SPACE`, `KEY_W`)
- `pressed` (optional): true for press, false for release
- `ctrl`, `shift`, `alt` (optional): Modifier keys
### simulate_mouse_click
Simulate a mouse button click at a position in the running game.
- `x`, `y` (optional): Viewport position
- `button` (optional): 1=left, 2=right, 3=middle
- `pressed` (optional): true for press, false for release
### simulate_mouse_move
Simulate mouse movement in the running game.
- `x`, `y` (optional): Target position
- `relative_x`, `relative_y` (optional): Relative movement
### simulate_action
Simulate a Godot Input Action in the running game.
- `action` (required): Action name from Input Map
- `pressed` (optional): true for press, false for release
- `strength` (optional): 0.01.0
### simulate_sequence
Simulate a sequence of input events with frame delays.
- `events` (required): Array of input events
- `frame_delay` (optional): Frames between events
## Runtime Tools
### get_game_scene_tree
Get the scene tree of the currently running game.
- `max_depth` (optional): Maximum tree depth
### get_game_node_properties
Get properties of a node in the running game.
- `node_path` (required): Absolute node path
- `properties` (optional): Specific property names to read
### capture_frames
Capture multiple screenshots at regular frame intervals from the running game.
- `count` (optional): Number of frames (130)
- `frame_interval` (optional): Frames between captures
- `half_resolution` (optional): Halve resolution to reduce data size
### monitor_properties
Record property values over multiple frames from the running game.
- `node_path` (required): Absolute node path
- `properties` (required): Property names to monitor
- `frame_count` (optional): Number of samples (1600)
- `frame_interval` (optional): Frames between samples
## Animation Tools
### list_animations
List all animations in an AnimationPlayer node.
- `node_path` (required): Path to the AnimationPlayer
### create_animation
Create a new animation in an AnimationPlayer.
- `node_path` (required): Path to the AnimationPlayer
- `name` (required): Animation name
- `length` (optional): Length in seconds (default: 1.0)
- `loop_mode` (optional): 0=none, 1=linear, 2=pingpong
### add_animation_track
Add a track to an animation.
- `node_path` (required): Path to the AnimationPlayer
- `animation` (required): Animation name
- `track_path` (required): Node path and property (e.g. `Sprite2D:position`)
- `track_type` (optional): value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape
- `update_mode` (optional): continuous, discrete, capture
### set_animation_keyframe
Insert a keyframe into an animation track.
- `node_path` (required): Path to the AnimationPlayer
- `animation` (required): Animation name
- `track_index` (required): Track index
- `time` (required): Time position in seconds
- `value` (required): Keyframe value (auto-parsed)
### get_animation_info
Get detailed info about an animation including all tracks and keyframes.
- `node_path` (required): Path to the AnimationPlayer
- `animation` (required): Animation name
### remove_animation
Remove an animation from an AnimationPlayer.
- `node_path` (required): Path to the AnimationPlayer
- `name` (required): Animation name
## TileMap Tools
### tilemap_set_cell
Set a single cell in a TileMapLayer.
- `node_path` (required): Path to the TileMapLayer
- `x`, `y` (required): Cell coordinates
- `source_id` (optional): Tile source ID
- `atlas_x`, `atlas_y` (optional): Atlas coordinates
- `alternative` (optional): Alternative tile ID
### tilemap_fill_rect
Fill a rectangular region with tiles.
- `node_path` (required): Path to the TileMapLayer
- `x1`, `y1`, `x2`, `y2` (required): Rectangle bounds
- `source_id`, `atlas_x`, `atlas_y`, `alternative` (optional): Tile data
### tilemap_get_cell
Get tile data at a specific cell.
- `node_path` (required): Path to the TileMapLayer
- `x`, `y` (required): Cell coordinates
### tilemap_clear
Clear all cells in a TileMapLayer.
- `node_path` (required): Path to the TileMapLayer
### tilemap_get_info
Get TileMapLayer info including tile set sources and cell count.
- `node_path` (required): Path to the TileMapLayer
### tilemap_get_used_cells
Get a list of used (non-empty) cells.
- `node_path` (required): Path to the TileMapLayer
- `max_count` (optional): Maximum cells to return (default: 500)
## Theme Tools
### create_theme
Create a new Theme resource file.
- `path` (required): Save path (e.g. `res://themes/main.tres`)
- `default_font_size` (optional): Default font size
### set_theme_color
Set a theme color override on a Control node.
- `node_path` (required): Path to the Control node
- `name` (required): Color name (e.g. `font_color`)
- `color` (required): Hex color string
### set_theme_constant
Set a theme constant override on a Control node.
- `node_path` (required): Path to the Control node
- `name` (required): Constant name
- `value` (required): Integer value
### set_theme_font_size
Set a theme font size override on a Control node.
- `node_path` (required): Path to the Control node
- `name` (required): Font size name (e.g. `font_size`)
- `size` (required): Font size in pixels
### set_theme_stylebox
Set a StyleBoxFlat override on a Control node.
- `node_path` (required): Path to the Control node
- `name` (required): Style name (e.g. `panel`, `normal`)
- `bg_color` (optional): Background color
- `border_color` (optional): Border color
- `border_width` (optional): Border width
- `corner_radius` (optional): Corner radius
- `padding` (optional): Content padding
### get_theme_info
Get theme information and overrides for a Control node.
- `node_path` (required): Path to the Control node
## Profiling Tools
### get_performance_monitors
Get all Godot performance monitors (FPS, memory, draw calls, physics, navigation).
- `category` (optional): Filter by prefix (e.g. `render`, `physics_2d`)
### get_editor_performance
Get a quick performance summary (FPS, frame time, draw calls, memory).
## Batch & Refactoring Tools
### find_nodes_by_type
Find all nodes of a specific type in the current scene.
- `type` (required): Node class name
- `recursive` (optional): Search recursively (default: true)
### find_signal_connections
Find all signal connections in the current scene.
- `signal_name` (optional): Filter by signal name
- `node_path` (optional): Filter by node path
### batch_set_property
Set a property on all nodes of a given type.
- `type` (required): Node type to target
- `property` (required): Property name
- `value` (required): Value to set (auto-parsed)
### find_node_references
Search through project files for a text pattern.
- `pattern` (required): Text pattern to search for
### get_scene_dependencies
Get all resource dependencies of a scene or resource file.
- `path` (required): Path to the file
## Shader Tools
### create_shader
Create a new shader file with template or custom content.
- `path` (required): Shader file path
- `shader_type` (optional): spatial, canvas_item, particles, sky
- `content` (optional): Full shader code
### read_shader
Read the content of a shader file.
- `path` (required): Path to the shader file
### edit_shader
Edit a shader file using full replacement or search-and-replace.
- `path` (required): Path to the shader file
- `content` (optional): Full replacement content
- `replacements` (optional): Array of `{search, replace}` operations
### assign_shader_material
Create a ShaderMaterial from a shader and assign to a node.
- `node_path` (required): Target node path
- `shader_path` (required): Path to the shader file
### set_shader_param
Set a shader parameter on a node's ShaderMaterial.
- `node_path` (required): Node with ShaderMaterial
- `param` (required): Parameter name
- `value` (required): Parameter value (auto-parsed)
### get_shader_params
Get all shader parameters from a node's ShaderMaterial.
- `node_path` (required): Node with ShaderMaterial
## Export Tools
### list_export_presets
List all export presets configured in export_presets.cfg.
### export_project
Get the export command for a preset.
- `preset_name` (optional): Preset name
- `preset_index` (optional): Preset index
- `debug` (optional): Debug export (default: true)
### get_export_info
Get export-related project info (executable path, templates directory, project path).

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,19 @@
# Godot MCP Pro — AI Client Instructions
Copy the appropriate file to your project root so your AI assistant knows how to use Godot MCP Pro.
| Client | File to copy | Destination |
|--------|-------------|-------------|
| Claude Code | `CLAUDE.md` | Project root |
| Codex CLI / OpenCode | `AGENTS.md` | Project root |
| Gemini CLI | `GEMINI.md` | Project root |
| Cursor | `godot-mcp-pro.mdc` | `.cursor/rules/godot-mcp-pro.mdc` |
| Cline | `.clinerules` | Project root |
| Windsurf | `.windsurfrules` | Project root |
| Roo Code | `roo-godot-mcp-pro.md` | `.roo/rules/roo-godot-mcp-pro.md` |
| JetBrains / Junie | `junie-guidelines.md` | `.junie/guidelines.md` |
| Amazon Q | `amazonq-godot-mcp-pro.md` | `.amazonq/rules/amazonq-godot-mcp-pro.md` |
| Continue | `continue-godot-mcp-pro.md` | `.continue/rules/godot-mcp-pro.md` |
| Augment Code | `augment-godot-mcp-pro.md` | `.augment/instructions/godot-mcp-pro.md` |
All files contain the same instructions adapted for each client's format.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,151 @@
---
description: Godot MCP Pro instructions for controlling Godot editor via MCP tools and CLI
alwaysApply: true
---
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,146 @@
# Godot MCP Pro - AI Assistant Instructions
You have access to the Godot MCP Pro toolset for building and testing Godot games through the editor. Follow these rules carefully.
## Critical: Editor vs Runtime Tools
Tools are split into two categories. **Using a runtime tool without starting the game will always fail.**
### Editor Tools (always available)
These work on the currently open scene in the Godot editor:
- **Scene**: `get_scene_tree`, `create_scene`, `open_scene`, `save_scene`, `delete_scene`, `add_scene_instance`, `get_scene_file_content`, `get_scene_exports`
- **Nodes**: `add_node`, `delete_node`, `duplicate_node`, `move_node`, `rename_node`, `update_property`, `get_node_properties`, `add_resource`, `set_anchor_preset`, `connect_signal`, `disconnect_signal`, `get_node_groups`, `set_node_groups`, `find_nodes_in_group`
- **Scripts**: `create_script`, `read_script`, `edit_script`, `validate_script`, `attach_script`, `get_open_scripts`, `list_scripts`
- **Project**: `get_project_info`, `get_project_settings`, `set_project_setting`, `get_project_statistics`, `get_filesystem_tree`, `get_input_actions`, `set_input_action`
- **Editor**: `execute_editor_script`, `get_editor_errors`, `get_output_log`, `get_editor_screenshot`, `clear_output`, `reload_plugin`, `reload_project`
- **Resources**: `create_resource`, `read_resource`, `edit_resource`, `get_resource_preview`
- **Batch**: `batch_add_nodes`, `batch_set_property`, `find_nodes_by_type`, `find_signal_connections`, `find_node_references`, `get_scene_dependencies`, `cross_scene_set_property`
- **3D**: `add_mesh_instance`, `setup_environment`, `setup_lighting`, `setup_camera_3d`, `setup_collision`, `setup_physics_body`, `set_material_3d`, `add_raycast`, `add_gridmap`
- **Animation**: `create_animation`, `add_animation_track`, `set_animation_keyframe`, `list_animations`, `get_animation_info`, `remove_animation`
- **Animation Tree**: `create_animation_tree`, `get_animation_tree_structure`, `add_state_machine_state`, `add_state_machine_transition`, `remove_state_machine_state`, `remove_state_machine_transition`, `set_blend_tree_node`, `set_tree_parameter`
- **Audio**: `add_audio_player`, `add_audio_bus`, `add_audio_bus_effect`, `set_audio_bus`, `get_audio_bus_layout`, `get_audio_info`
- **Navigation**: `setup_navigation_region`, `setup_navigation_agent`, `bake_navigation_mesh`, `set_navigation_layers`, `get_navigation_info`
- **Particles**: `create_particles`, `set_particle_material`, `set_particle_color_gradient`, `apply_particle_preset`, `get_particle_info`
- **Physics**: `get_physics_layers`, `set_physics_layers`, `get_collision_info`
- **Shader**: `create_shader`, `read_shader`, `edit_shader`, `assign_shader_material`, `get_shader_params`, `set_shader_param`
- **Theme**: `create_theme`, `get_theme_info`, `set_theme_color`, `set_theme_font_size`, `set_theme_constant`, `set_theme_stylebox`
- **Tilemap**: `tilemap_get_info`, `tilemap_set_cell`, `tilemap_get_cell`, `tilemap_fill_rect`, `tilemap_clear`, `tilemap_get_used_cells`
- **Export**: `list_export_presets`, `get_export_info`, `export_project`
- **Analysis**: `analyze_scene_complexity`, `analyze_signal_flow`, `detect_circular_dependencies`, `find_unused_resources`, `get_performance_monitors`, `search_files`, `search_in_files`, `find_script_references`
- **Profiling**: `get_editor_performance`
### Runtime Tools (require `play_scene` first)
You MUST call `play_scene` before using any of these. They interact with the running game:
- **Game State**: `get_game_scene_tree`, `get_game_node_properties`, `set_game_node_property`, `execute_game_script`, `get_game_screenshot`, `get_autoload`, `find_nodes_by_script`
- **Input Simulation**: `simulate_key`, `simulate_mouse_click`, `simulate_mouse_move`, `simulate_action`, `simulate_sequence`
- **Capture/Recording**: `capture_frames`, `record_frames`, `monitor_properties`, `start_recording`, `stop_recording`, `replay_recording`, `batch_get_properties`
- **UI Interaction**: `find_ui_elements`, `click_button_by_text`, `wait_for_node`, `find_nearby_nodes`, `navigate_to`, `move_to`
- **Testing**: `run_test_scenario`, `assert_node_state`, `assert_screen_text`, `run_stress_test`, `get_test_report`
- **Screenshots**: `get_game_screenshot`, `compare_screenshots`
- **Control**: `play_scene`, `stop_scene`
## Workflow Patterns
### Building a scene from scratch
1. `create_scene` or `open_scene`
2. Use `add_node` or `batch_add_nodes` to add nodes
3. `create_script` + `attach_script` for behavior
4. `save_scene`
### Testing gameplay
1. Build scene with editor tools (above)
2. `play_scene` to start the game
3. Use `simulate_key`/`simulate_mouse_click` for input
4. `get_game_screenshot` or `capture_frames` to observe results
5. `stop_scene` when done
### Inspecting a project
1. `get_project_info` for overview
2. `get_scene_tree` for current scene structure
3. `read_script` to read code
4. `get_node_properties` for specific node details
### Migrating code properties to inspector
When a script hardcodes visual properties (colors, sizes, positions, theme overrides) that should be in the inspector:
1. `read_script` to find hardcoded property assignments (e.g. `modulate = Color(...)`, `add_theme_color_override(...)`)
2. `get_node_properties` to see current inspector values
3. `update_property` to set the same values as node properties in the inspector
4. `edit_script` to remove the hardcoded lines from the script
5. `save_scene` to persist the inspector changes
6. `validate_script` to verify the script still works
## Formatting Rules
### execute_editor_script
The `code` parameter must be valid GDScript. Use `_mcp_print(value)` to return output.
```
# Correct
_mcp_print("hello")
# Correct - multi-line
var nodes = []
for child in EditorInterface.get_edited_scene_root().get_children():
nodes.append(child.name)
_mcp_print(str(nodes))
```
### execute_game_script
Same as above but runs inside the running game. Additional rules:
- No nested functions (`func` inside `func` is invalid GDScript)
- Use `.get("property")` instead of `.property` for safe access
- Runs in a temporary node — use `get_tree()` to access the scene tree
### batch_add_nodes
Pass an array of node definitions. Nodes are processed in order, so earlier nodes can be parents for later ones:
```json
{
"nodes": [
{"type": "Node2D", "name": "Container", "parent_path": "."},
{"type": "Sprite2D", "name": "Icon", "parent_path": "Container"},
{"type": "Label", "name": "Title", "parent_path": "Container", "properties": {"text": "Hello"}}
]
}
```
## Best Practices
1. **Prefer inspector properties over code** — When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak. Only use GDScript when the property isn't available in the inspector or needs to be dynamic at runtime.
## Common Pitfalls
1. **Never edit project.godot directly** — Use `set_project_setting` instead. The Godot editor overwrites the file.
2. **GDScript type inference** — Use explicit type annotations in for-loops: `for item: String in array` instead of `for item in array`.
3. **Reload after script changes** — After `create_script`, call `reload_project` if the script doesn't take effect.
4. **Property values as strings** — Properties like position accept string format: `"Vector2(100, 200)"`, `"Color(1, 0, 0, 1)"`.
5. **simulate_key duration** — Use short durations (0.3-0.5s) for precise movement. Integer seconds (1, 2, 3) cause overshooting.
6. **compare_screenshots** — Pass file paths (`user://screenshot.png`), not base64 data.
## CLI Mode (Alternative to MCP Tools)
If MCP tools are unavailable or you have a terminal/bash tool, you can control Godot via the CLI.
The CLI requires the server to be built first (`node build/setup.js install` in the server directory).
```bash
# Discover available command groups
node /path/to/server/build/cli.js --help
# Discover commands in a group
node /path/to/server/build/cli.js scene --help
# Discover options for a specific command
node /path/to/server/build/cli.js node add --help
# Execute commands
node /path/to/server/build/cli.js project info
node /path/to/server/build/cli.js scene tree
node /path/to/server/build/cli.js node add --type CharacterBody3D --name Player --parent /root/Main
node /path/to/server/build/cli.js script read --path res://player.gd
node /path/to/server/build/cli.js scene play
node /path/to/server/build/cli.js input key --key W --duration 0.5
node /path/to/server/build/cli.js runtime tree
```
**Command groups**: project, scene, node, script, editor, input, runtime
Always start by running `--help` to discover available commands. Use the CLI when MCP tools are not loaded or when you need to reduce context usage.

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* godot-cli — Command-line interface for Godot MCP Pro
*
* Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0).
* Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits.
* Progressive disclosure via --help at each command level.
*/
export {};
//# sourceMappingURL=cli.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;GAMG"}

View File

@@ -0,0 +1,705 @@
#!/usr/bin/env node
/**
* godot-cli — Command-line interface for Godot MCP Pro
*
* Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0).
* Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits.
* Progressive disclosure via --help at each command level.
*/
import { WebSocketServer } from "ws";
import { randomUUID } from "crypto";
import { createServer } from "net";
const BASE_PORT = 6510;
const MAX_PORT = 6514;
const CONNECT_TIMEOUT_MS = 10000;
const COMMAND_TIMEOUT_MS = 30000;
const COMMANDS = {
project: {
description: "Project info, files, and settings",
commands: {
info: {
description: "Get project metadata (name, version, viewport, renderer, autoloads)",
method: "get_project_info",
},
files: {
description: "List project file/directory tree",
method: "get_filesystem_tree",
args: {
path: { description: "Root path (default: res://)" },
filter: { description: "Glob filter (e.g. '*.gd', '*.tscn')" },
},
},
search: {
description: "Search for files by name pattern",
method: "search_files",
args: {
query: { description: "Search query (fuzzy match or glob)", required: true },
path: { description: "Directory to search in" },
file_type: { description: "Filter by extension (e.g. 'gd', 'tscn')" },
},
},
grep: {
description: "Search inside file contents",
method: "search_in_files",
args: {
query: { description: "Text/regex pattern", required: true },
path: { description: "Directory to search in" },
file_type: { description: "File extension filter (e.g. 'gd', 'tscn')" },
},
},
"get-setting": {
description: "Get project settings",
method: "get_project_settings",
args: {
category: { description: "Settings category filter" },
},
},
"set-setting": {
description: "Set a project setting",
method: "set_project_setting",
args: {
setting: { description: "Setting path (e.g. display/window/size/viewport_width)", required: true },
value: { description: "Value to set", required: true },
},
mapArgs: (p) => ({ setting: p.setting, value: autoType(p.value) }),
},
},
},
scene: {
description: "Scene tree and scene management",
commands: {
tree: {
description: "Get the current scene tree",
method: "get_scene_tree",
args: {
max_depth: { description: "Maximum depth to display", type: "number" },
},
mapArgs: (p) => (p.max_depth ? { max_depth: parseInt(p.max_depth) } : {}),
},
create: {
description: "Create a new scene with a root node",
method: "create_scene",
args: {
path: { description: "Scene path (e.g. res://scenes/player.tscn)", required: true },
root_type: { description: "Root node type (default: Node2D)" },
root_name: { description: "Root node name" },
},
},
open: {
description: "Open a scene in the editor",
method: "open_scene",
args: {
path: { description: "Scene path to open", required: true },
},
},
save: {
description: "Save the current scene",
method: "save_scene",
args: {
path: { description: "Optional path to save as" },
},
},
play: {
description: "Run the current/specified scene",
method: "play_scene",
args: {
mode: { description: "'main' (default), 'current', or a scene path" },
},
},
stop: {
description: "Stop the running scene",
method: "stop_scene",
},
content: {
description: "Get scene file content (tscn format parsed)",
method: "get_scene_file_content",
args: {
path: { description: "Scene file path", required: true },
},
},
exports: {
description: "Get exported variables of a scene",
method: "get_scene_exports",
args: {
path: { description: "Scene file path", required: true },
},
},
delete: {
description: "Delete a scene file",
method: "delete_scene",
args: {
path: { description: "Scene file path to delete", required: true },
},
},
instance: {
description: "Add a scene instance as child node",
method: "add_scene_instance",
args: {
scene_path: { description: "Path to .tscn file to instance", required: true },
parent_path: { description: "Parent node path (default: selected/root)" },
name: { description: "Instance name" },
},
},
},
},
node: {
description: "Add, modify, and delete scene nodes",
commands: {
add: {
description: "Add a new node to the scene",
method: "add_node",
args: {
type: { description: "Node type (e.g. CharacterBody3D, Sprite2D)", required: true },
name: { description: "Node name" },
parent_path: { description: "Parent node path (default: root '.')" },
},
},
delete: {
description: "Delete a node from the scene",
method: "delete_node",
args: {
node_path: { description: "Node path to delete", required: true },
},
},
get: {
description: "Get all properties of a node",
method: "get_node_properties",
args: {
node_path: { description: "Node path", required: true },
},
},
set: {
description: "Set a property on a node",
method: "update_property",
args: {
node_path: { description: "Node path", required: true },
property: { description: "Property name", required: true },
value: { description: "Value to set", required: true },
},
mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }),
},
duplicate: {
description: "Duplicate a node",
method: "duplicate_node",
args: {
node_path: { description: "Node path to duplicate", required: true },
name: { description: "Name for the duplicate" },
},
},
move: {
description: "Move/reparent a node",
method: "move_node",
args: {
node_path: { description: "Node path to move", required: true },
new_parent_path: { description: "New parent path", required: true },
},
},
rename: {
description: "Rename a node",
method: "rename_node",
args: {
node_path: { description: "Node path", required: true },
new_name: { description: "New name", required: true },
},
},
connect: {
description: "Connect a signal between nodes",
method: "connect_signal",
args: {
source_path: { description: "Source node path", required: true },
signal_name: { description: "Signal name", required: true },
target_path: { description: "Target node path", required: true },
method_name: { description: "Target method name", required: true },
},
},
groups: {
description: "Get groups a node belongs to",
method: "get_node_groups",
args: {
node_path: { description: "Node path", required: true },
},
},
},
},
script: {
description: "Read, create, and edit GDScript/C# files",
commands: {
read: {
description: "Read a script file",
method: "read_script",
args: {
path: { description: "Script path (e.g. res://player.gd)", required: true },
},
},
create: {
description: "Create a new script file (.gd or .cs only)",
method: "create_script",
args: {
path: { description: "Script path", required: true },
content: { description: "Script content", required: true },
base_type: { description: "Base class (default: Node)" },
force: { description: "Override open-script-editor guard" },
},
mapArgs: (p) => {
const r = { path: p.path, content: p.content };
if (p.base_type)
r.base_type = p.base_type;
if (p.force !== undefined)
r.force = p.force === "true";
return r;
},
},
edit: {
description: "Edit an existing script (full replace or 1-based inclusive line range)",
method: "edit_script",
args: {
path: { description: "Script path", required: true },
content: { description: "New content", required: true },
start_line: { description: "Start line for partial edit (1-based inclusive)", type: "number" },
end_line: { description: "End line for partial edit (1-based inclusive)", type: "number" },
force: { description: "Override open-script-editor guard" },
},
mapArgs: (p) => {
const r = { path: p.path, content: p.content };
if (p.start_line)
r.start_line = parseInt(p.start_line);
if (p.end_line)
r.end_line = parseInt(p.end_line);
if (p.force !== undefined)
r.force = p.force === "true";
return r;
},
},
attach: {
description: "Attach a script to a node",
method: "attach_script",
args: {
node_path: { description: "Node path", required: true },
script_path: { description: "Script path", required: true },
},
},
validate: {
description: "Validate a GDScript for errors",
method: "validate_script",
args: {
path: { description: "Script path to validate", required: true },
},
},
list: {
description: "List all scripts in the project",
method: "list_scripts",
},
},
},
editor: {
description: "Editor state, errors, screenshots, and utilities",
commands: {
errors: {
description: "Get current editor errors/warnings",
method: "get_editor_errors",
},
log: {
description: "Get editor output log",
method: "get_output_log",
args: {
lines: { description: "Number of lines (default: 50)", type: "number" },
},
mapArgs: (p) => (p.lines ? { lines: parseInt(p.lines) } : {}),
},
screenshot: {
description: "Take a screenshot of the running game",
method: "get_game_screenshot",
},
"editor-screenshot": {
description: "Take a screenshot of the editor",
method: "get_editor_screenshot",
},
exec: {
description: "Execute an editor script (GDScript in editor context)",
method: "execute_editor_script",
args: {
code: { description: "GDScript code to execute", required: true },
},
},
signals: {
description: "Get signals of a node type",
method: "get_signals",
args: {
node_path: { description: "Node path", required: true },
},
},
reload: {
description: "Reload the project",
method: "reload_project",
},
},
},
input: {
description: "Simulate keyboard, mouse, and input actions",
commands: {
key: {
description: "Simulate a key press in the running game",
method: "simulate_key",
args: {
key: { description: "Key name (e.g. W, A, S, D, Space)", required: true },
duration: { description: "Hold duration in seconds", type: "number" },
pressed: { description: "true=press, false=release" },
},
mapArgs: (p) => {
const r = { key: p.key };
if (p.duration)
r.duration = parseFloat(p.duration);
if (p.pressed !== undefined)
r.pressed = p.pressed === "true";
return r;
},
},
click: {
description: "Simulate a mouse click in the running game",
method: "simulate_mouse_click",
args: {
x: { description: "X coordinate", required: true, type: "number" },
y: { description: "Y coordinate", required: true, type: "number" },
button: { description: "Mouse button: left, right, or middle (default: left)" },
},
mapArgs: (p) => {
const buttonMap = { left: 1, right: 2, middle: 3 };
const r = { x: parseInt(p.x), y: parseInt(p.y) };
if (p.button)
r.button = buttonMap[p.button.toLowerCase()] ?? (parseInt(p.button) || 1);
return r;
},
},
action: {
description: "Simulate an input action (as defined in Input Map)",
method: "simulate_action",
args: {
action: { description: "Action name (e.g. ui_accept, move_left)", required: true },
pressed: { description: "true=press, false=release" },
duration: { description: "Hold duration in seconds", type: "number" },
},
mapArgs: (p) => {
const r = { action: p.action };
if (p.pressed !== undefined)
r.pressed = p.pressed === "true";
if (p.duration)
r.duration = parseFloat(p.duration);
return r;
},
},
actions: {
description: "List all configured input actions",
method: "get_input_actions",
},
},
},
runtime: {
description: "Inspect and control the running game",
commands: {
tree: {
description: "Get the running game's scene tree",
method: "get_game_scene_tree",
args: {
max_depth: { description: "Max depth (-1 for unlimited)", type: "number" },
},
mapArgs: (p) => {
const r = {};
if (p.max_depth)
r.max_depth = parseInt(p.max_depth);
return r;
},
},
get: {
description: "Get properties of a node in the running game",
method: "get_game_node_properties",
args: {
node_path: { description: "Node path", required: true },
properties: { description: "Comma-separated property names (default: all)" },
},
mapArgs: (p) => {
const r = { node_path: p.node_path };
if (p.properties)
r.properties = p.properties.split(",").map(s => s.trim());
return r;
},
},
set: {
description: "Set a property on a running game node",
method: "set_game_node_property",
args: {
node_path: { description: "Node path", required: true },
property: { description: "Property name", required: true },
value: { description: "Value to set", required: true },
},
mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }),
},
exec: {
description: "Execute GDScript in the running game",
method: "execute_game_script",
args: {
code: { description: "GDScript code", required: true },
node_path: { description: "Node context (default: /root)" },
},
},
ui: {
description: "Find UI elements (buttons, labels, etc.) in the running game",
method: "find_ui_elements",
args: {
type_filter: { description: "Filter by type (Button, Label, etc.)" },
},
},
},
},
};
// ─── Argument parsing ─────────────────────────────────────────────────
function autoType(value) {
if (value === "true")
return true;
if (value === "false")
return false;
if (value === "null")
return null;
const num = Number(value);
if (!isNaN(num) && value.trim() !== "")
return num;
// Try JSON for arrays/objects
if ((value.startsWith("[") || value.startsWith("{")) && (value.endsWith("]") || value.endsWith("}"))) {
try {
return JSON.parse(value);
}
catch { /* fall through */ }
}
return value;
}
function parseArgs(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
flags[key] = next;
i++;
}
else {
flags[key] = "true";
}
}
else {
positional.push(arg);
}
}
return { positional, flags };
}
// ─── Help formatting ──────────────────────────────────────────────────
function showMainHelp() {
console.log(`godot-cli — Control Godot editor from the command line
Usage: godot-cli <group> <command> [options]
Groups:`);
for (const [name, group] of Object.entries(COMMANDS)) {
console.log(` ${name.padEnd(12)} ${group.description}`);
}
console.log(`
Options:
--port <N> Godot WebSocket port (default: auto-detect 6510-6514)
--help Show help for a group or command
Examples:
godot-cli project info
godot-cli scene tree
godot-cli node add --type CharacterBody3D --name Player
godot-cli script read --path res://player.gd
godot-cli scene play
godot-cli input key --key W --duration 0.5`);
}
function showGroupHelp(groupName, group) {
console.log(`godot-cli ${groupName}${group.description}
Commands:`);
for (const [name, cmd] of Object.entries(group.commands)) {
console.log(` ${name.padEnd(18)} ${cmd.description}`);
}
console.log(`\nUse: godot-cli ${groupName} <command> --help for details`);
}
function showCommandHelp(groupName, cmdName, cmd) {
console.log(`godot-cli ${groupName} ${cmdName}${cmd.description}`);
if (cmd.args && Object.keys(cmd.args).length > 0) {
console.log(`\nOptions:`);
for (const [name, arg] of Object.entries(cmd.args)) {
const req = arg.required ? " (required)" : "";
console.log(` --${name.padEnd(16)} ${arg.description}${req}`);
}
}
}
// ─── WebSocket connection ─────────────────────────────────────────────
// The Godot plugin is a WebSocket CLIENT that connects to servers on ports 6505-6514.
// The CLI starts a temporary WebSocket SERVER on an available port and waits for
// the Godot plugin to connect (it polls every 3 seconds).
function isPortFree(port) {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.close(() => resolve(true));
});
server.listen(port, "127.0.0.1");
});
}
async function findFreePort(preferredPort) {
if (preferredPort) {
if (await isPortFree(preferredPort))
return preferredPort;
return null;
}
for (let p = BASE_PORT; p <= MAX_PORT; p++) {
if (await isPortFree(p))
return p;
}
return null;
}
/**
* Start a WebSocket server and wait for the Godot plugin to connect.
* Returns the connected client WebSocket and the server (for cleanup).
*/
function waitForGodot(port) {
return new Promise((resolve, reject) => {
const wss = new WebSocketServer({ port, host: "127.0.0.1" });
const timeout = setTimeout(() => {
wss.close();
reject(new Error(`Godot plugin did not connect within ${CONNECT_TIMEOUT_MS / 1000}s.\n` +
"Make sure the Godot editor is running with the MCP plugin enabled."));
}, CONNECT_TIMEOUT_MS);
wss.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
wss.on("connection", (ws) => {
clearTimeout(timeout);
resolve({ client: ws, wss });
});
});
}
function sendCommand(ws, method, params) {
return new Promise((resolve, reject) => {
const id = randomUUID();
const timeout = setTimeout(() => {
reject(new Error(`Command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`));
}, COMMAND_TIMEOUT_MS);
const handler = (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
}
catch {
return;
}
// Ignore ping/pong
if (msg.method === "pong" || msg.method === "ping")
return;
if (msg.id !== id)
return;
clearTimeout(timeout);
ws.off("message", handler);
if (msg.error) {
reject(new Error(`Godot error: ${msg.error.message || JSON.stringify(msg.error)}`));
}
else {
resolve(msg.result);
}
};
ws.on("message", handler);
ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id }));
});
}
// ─── Main ─────────────────────────────────────────────────────────────
async function main() {
const userArgs = process.argv.slice(2);
const { positional, flags } = parseArgs(userArgs);
// Global --help
if (positional.length === 0 || flags.help === "true" && positional.length === 0) {
showMainHelp();
process.exit(0);
}
const groupName = positional[0];
const group = COMMANDS[groupName];
if (!group) {
console.error(`Unknown group: ${groupName}`);
showMainHelp();
process.exit(1);
}
// Group-level --help
if (positional.length === 1 || (flags.help === "true" && positional.length === 1)) {
showGroupHelp(groupName, group);
process.exit(0);
}
const cmdName = positional[1];
const cmd = group.commands[cmdName];
if (!cmd) {
console.error(`Unknown command: ${groupName} ${cmdName}`);
showGroupHelp(groupName, group);
process.exit(1);
}
// Command-level --help
if (flags.help === "true") {
showCommandHelp(groupName, cmdName, cmd);
process.exit(0);
}
// Validate required args
if (cmd.args) {
for (const [name, arg] of Object.entries(cmd.args)) {
if (arg.required && !flags[name]) {
console.error(`Missing required option: --${name}`);
showCommandHelp(groupName, cmdName, cmd);
process.exit(1);
}
}
}
// Build params
const params = cmd.mapArgs ? cmd.mapArgs(flags) : { ...flags };
// Remove internal flags
delete params.port;
delete params.help;
// Connect and execute
const preferredPort = flags.port ? parseInt(flags.port) : undefined;
const port = await findFreePort(preferredPort);
if (!port) {
console.error(`No free ports in range ${BASE_PORT}-${MAX_PORT}.\n` +
"All ports are occupied by MCP server instances.");
process.exit(1);
}
let client;
let wss;
try {
process.stderr.write(`Waiting for Godot on port ${port}...`);
({ client, wss } = await waitForGodot(port));
process.stderr.write(" connected!\n");
}
catch (err) {
process.stderr.write("\n");
console.error(err.message);
process.exit(1);
}
try {
const result = await sendCommand(client, cmd.method, params);
if (result !== undefined && result !== null) {
console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
}
}
catch (err) {
console.error(err.message);
process.exit(1);
}
finally {
client.close();
wss.close();
}
}
main().catch((err) => {
console.error("Fatal:", err.message);
process.exit(1);
});
//# sourceMappingURL=cli.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
export declare class GodotConnection {
private wss;
private client;
private port;
private fixedPort;
private basePort;
private maxPort;
private pendingRequests;
private heartbeatTimer;
private lastPongAt;
constructor(port?: number, fixedPort?: boolean, options?: {
basePort?: number;
maxPort?: number;
});
/** Start WebSocket server, retrying on the next port if the first bind races. */
connect(): Promise<void>;
/** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */
private bindWebSocketServer;
private attachConnectionHandler;
disconnect(): void;
isConnected(): boolean;
getPort(): number;
sendCommand(method: string, params?: Record<string, unknown>): Promise<unknown>;
private handleMessage;
private rejectAllPending;
private startHeartbeat;
private stopHeartbeat;
}
//# sourceMappingURL=godot-connection.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"godot-connection.d.ts","sourceRoot":"","sources":["../src/godot-connection.ts"],"names":[],"mappings":"AAoBA,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,eAAe,CAA0C;IACjE,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,UAAU,CAAa;gBAG7B,IAAI,GAAE,MAAkB,EACxB,SAAS,GAAE,OAAe,EAC1B,OAAO,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;IAQvD,iFAAiF;IAC3E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C9B,oGAAoG;IACpG,OAAO,CAAC,mBAAmB;IAwB3B,OAAO,CAAC,uBAAuB;IAsC/B,UAAU,IAAI,IAAI;IAalB,WAAW,IAAI,OAAO;IAItB,OAAO,IAAI,MAAM;IAIX,WAAW,CACf,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC,OAAO,CAAC,OAAO,CAAC;IA8BnB,OAAO,CAAC,aAAa;IA6CrB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,cAAc;IA4BtB,OAAO,CAAC,aAAa;CAMtB"}

View File

@@ -0,0 +1,226 @@
import { WebSocketServer, WebSocket } from "ws";
import { randomUUID } from "crypto";
import { GodotConnectionError, GodotCommandError, TimeoutError, } from "./utils/errors.js";
const BASE_PORT = 6505;
const MAX_PORT = 6509;
const COMMAND_TIMEOUT_MS = 30000;
const HEARTBEAT_INTERVAL_MS = 10000;
const HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 3;
const TCP_KEEPALIVE_DELAY_MS = 5000;
export class GodotConnection {
wss = null;
client = null;
port;
fixedPort;
basePort;
maxPort;
pendingRequests = new Map();
heartbeatTimer = null;
lastPongAt = 0;
constructor(port = BASE_PORT, fixedPort = false, options = {}) {
this.port = port;
this.fixedPort = fixedPort;
this.basePort = options.basePort ?? BASE_PORT;
this.maxPort = options.maxPort ?? MAX_PORT;
}
/** Start WebSocket server, retrying on the next port if the first bind races. */
async connect() {
if (this.wss)
return;
const candidates = this.fixedPort
? [this.port]
: Array.from({ length: this.maxPort - this.basePort + 1 }, (_, i) => this.basePort + i);
let lastError = null;
for (const port of candidates) {
try {
const wss = await this.bindWebSocketServer(port);
this.wss = wss;
this.port = port;
this.attachConnectionHandler(wss);
console.error(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}`);
return;
}
catch (err) {
lastError = err;
// EADDRINUSE means another MCP server (likely a parallel Claude session)
// won the bind race. Silently try the next port. Other errors are
// logged so we don't swallow real config problems.
if (err.code !== "EADDRINUSE") {
console.error(`[MCP] Bind failed on port ${port}: ${err.message}`);
}
}
}
const range = this.fixedPort
? String(this.port)
: `${this.basePort}-${this.maxPort}`;
const hint = this.fixedPort
? "Try removing GODOT_MCP_PORT from your client config to enable auto-scanning, or kill the process holding the port."
: "All ports are occupied — likely too many parallel Claude Code sessions or stale node MCP processes.";
throw new GodotConnectionError(`Failed to bind WebSocket server on port range ${range}. ` +
`Last error: ${lastError?.message ?? "unknown"}. ${hint}`);
}
/** Try to bind a single WebSocketServer. Resolves once 'listening' fires, rejects on bind error. */
bindWebSocketServer(port) {
return new Promise((resolve, reject) => {
const wss = new WebSocketServer({ port, host: "127.0.0.1" });
const onError = (err) => {
wss.off("listening", onListening);
wss.close();
reject(err);
};
const onListening = () => {
wss.off("error", onError);
// Re-attach a runtime error handler now that the server is live.
// Pre-bind errors fail the connect attempt; post-bind errors are logged.
wss.on("error", (err) => {
console.error("[MCP] WebSocket server error:", err.message);
});
resolve(wss);
};
wss.once("error", onError);
wss.once("listening", onListening);
});
}
attachConnectionHandler(wss) {
wss.on("connection", (ws) => {
console.error("[MCP] Godot editor connected");
// Enable OS-level TCP keepalive so half-open sockets surface faster
// than the Windows default (~2 hours). Application-level heartbeat
// below is still the primary detection mechanism.
const sock = ws._socket;
sock?.setKeepAlive?.(true, TCP_KEEPALIVE_DELAY_MS);
if (this.client) {
this.client.close(1000, "Replaced by new connection");
}
this.client = ws;
this.lastPongAt = Date.now();
this.startHeartbeat();
ws.on("message", (data) => {
this.handleMessage(data.toString());
});
ws.on("close", () => {
console.error("[MCP] Godot editor disconnected");
if (this.client === ws) {
this.client = null;
this.stopHeartbeat();
this.rejectAllPending(new GodotConnectionError("Godot disconnected"));
}
});
ws.on("error", (err) => {
console.error("[MCP] WebSocket error:", err.message);
});
});
}
disconnect() {
this.stopHeartbeat();
if (this.client) {
this.client.close(1000, "Server shutting down");
this.client = null;
}
if (this.wss) {
this.wss.close();
this.wss = null;
}
this.rejectAllPending(new GodotConnectionError("Server shut down"));
}
isConnected() {
return this.client?.readyState === WebSocket.OPEN;
}
getPort() {
return this.port;
}
async sendCommand(method, params = {}) {
if (!this.isConnected()) {
throw new GodotConnectionError("Godot editor is not connected. Make sure the Godot MCP Pro plugin is enabled and the editor is running.");
}
const id = randomUUID();
const request = {
jsonrpc: "2.0",
method,
params,
id,
};
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new TimeoutError(method, COMMAND_TIMEOUT_MS));
}, COMMAND_TIMEOUT_MS);
this.pendingRequests.set(id, {
resolve: resolve,
reject,
timer,
});
this.client.send(JSON.stringify(request));
});
}
handleMessage(data) {
let msg;
try {
msg = JSON.parse(data);
}
catch {
console.error("[MCP] Failed to parse message from Godot:", data);
return;
}
const method = msg.method;
if (method === "pong") {
this.lastPongAt = Date.now();
return;
}
// Godot may also send unsolicited pings — reply so its inactivity timer resets
if (method === "ping") {
this.lastPongAt = Date.now();
if (this.isConnected()) {
this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "pong", params: {} }));
}
return;
}
if (!msg.id)
return;
const pending = this.pendingRequests.get(msg.id);
if (!pending)
return;
clearTimeout(pending.timer);
this.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new GodotCommandError(msg.error.code, msg.error.message, msg.error.data));
}
else {
pending.resolve(msg.result);
}
}
rejectAllPending(error) {
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pendingRequests.clear();
}
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (!this.isConnected())
return;
// If Godot has been silent for too long, the socket is likely half-open.
// terminate() forcibly destroys the TCP socket (vs close() which waits
// for a FIN ack that will never arrive on a dead link).
if (Date.now() - this.lastPongAt > HEARTBEAT_TIMEOUT_MS) {
console.error(`[MCP] Heartbeat timeout (no pong for ${HEARTBEAT_TIMEOUT_MS}ms) — terminating dead connection`);
const dead = this.client;
this.client = null;
this.stopHeartbeat();
this.rejectAllPending(new GodotConnectionError("Heartbeat timeout — Godot connection lost"));
dead?.terminate();
return;
}
this.client.send(JSON.stringify({ jsonrpc: "2.0", method: "ping", params: {} }));
}, HEARTBEAT_INTERVAL_MS);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
//# sourceMappingURL=godot-connection.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=index.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { GodotConnection } from "./godot-connection.js";
import { registerProjectTools } from "./tools/project-tools.js";
import { registerSceneTools } from "./tools/scene-tools.js";
import { registerNodeTools } from "./tools/node-tools.js";
import { registerScriptTools } from "./tools/script-tools.js";
import { registerEditorTools } from "./tools/editor-tools.js";
import { registerInputTools } from "./tools/input-tools.js";
import { registerRuntimeTools } from "./tools/runtime-tools.js";
import { registerAnimationTools } from "./tools/animation-tools.js";
import { registerTilemapTools } from "./tools/tilemap-tools.js";
import { registerThemeTools } from "./tools/theme-tools.js";
import { registerProfilingTools } from "./tools/profiling-tools.js";
import { registerBatchTools } from "./tools/batch-tools.js";
import { registerShaderTools } from "./tools/shader-tools.js";
import { registerExportTools } from "./tools/export-tools.js";
import { registerResourceTools } from "./tools/resource-tools.js";
import { registerAnimationTreeTools } from "./tools/animation-tree-tools.js";
import { registerPhysicsTools } from "./tools/physics-tools.js";
import { registerScene3DTools } from "./tools/scene-3d-tools.js";
import { registerParticleTools } from "./tools/particle-tools.js";
import { registerNavigationTools } from "./tools/navigation-tools.js";
import { registerAudioTools } from "./tools/audio-tools.js";
import { registerTestTools } from "./tools/test-tools.js";
import { registerAnalysisTools } from "./tools/analysis-tools.js";
import { registerInputMapTools } from "./tools/input-map-tools.js";
import { registerAndroidTools } from "./tools/android-tools.js";
import { MINIMAL_TOOLS, createFilteredServer } from "./utils/tool-filter.js";
import { loadInstructions } from "./utils/load-instructions.js";
const MINIMAL_MODE = process.argv.includes("--minimal");
const THREED_MODE = process.argv.includes("--3d");
const LITE_MODE = process.argv.includes("--lite") || MINIMAL_MODE || THREED_MODE;
const HTTP_MODE = process.argv.includes("--http");
const HTTP_PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--http-port") ||
process.env.GODOT_MCP_HTTP_PORT ||
"8001");
const explicitPort = process.env.GODOT_MCP_PORT;
const godot = new GodotConnection(parseInt(explicitPort || "6505"), !!explicitPort);
const serverName = MINIMAL_MODE
? "godot-mcp-pro-minimal"
: THREED_MODE
? "godot-mcp-pro-3d"
: LITE_MODE
? "godot-mcp-pro-lite"
: "godot-mcp-pro";
const server = new McpServer({
name: serverName,
version: "1.14.1",
}, {
instructions: loadInstructions(),
});
// In minimal mode, wrap the server to filter tool registrations
const toolServer = MINIMAL_MODE ? createFilteredServer(server, MINIMAL_TOOLS) : server;
// Core tools (always registered)
registerProjectTools(toolServer, godot);
registerSceneTools(toolServer, godot);
registerNodeTools(toolServer, godot);
registerScriptTools(toolServer, godot);
registerEditorTools(toolServer, godot);
registerInputTools(toolServer, godot);
registerRuntimeTools(toolServer, godot);
registerInputMapTools(toolServer, godot);
// 3D-critical tools (registered in FULL and --3d modes)
// Core (81) + Physics (6) + AnimationTree (8) + Navigation (5) = exactly 100 tools
if (!LITE_MODE || THREED_MODE) {
registerPhysicsTools(server, godot);
registerAnimationTreeTools(server, godot);
registerNavigationTools(server, godot);
}
// Extended tools (Full mode only)
if (!LITE_MODE) {
registerAnimationTools(server, godot);
registerAudioTools(server, godot);
registerBatchTools(server, godot);
registerExportTools(server, godot);
registerParticleTools(server, godot);
registerProfilingTools(server, godot);
registerResourceTools(server, godot);
registerScene3DTools(server, godot);
registerShaderTools(server, godot);
registerTestTools(server, godot);
registerThemeTools(server, godot);
registerTilemapTools(server, godot);
registerAnalysisTools(server, godot);
registerAndroidTools(server, godot);
}
// Start server
async function main() {
// Attempt initial connection to Godot (non-blocking).
// If this fails (all ports occupied, etc.), tool calls will fail with a
// clear error message from sendCommand until the user restarts the server.
godot.connect().catch((err) => {
console.error(`[MCP] Failed to start WebSocket server: ${err.message}`);
});
if (HTTP_MODE) {
// Streamable HTTP transport — clients connect via http://host:port/mcp
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
const httpServer = createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname === "/mcp") {
await transport.handleRequest(req, res);
}
else {
res.writeHead(404).end("Not Found");
}
});
httpServer.listen(HTTP_PORT, () => {
const mode = MINIMAL_MODE ? "MINIMAL " : THREED_MODE ? "3D " : LITE_MODE ? "LITE " : "";
console.error(`[MCP] Godot MCP Pro ${mode}started (HTTP transport on http://127.0.0.1:${HTTP_PORT}/mcp)`);
});
}
else {
// Default stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
const modeLabel = MINIMAL_MODE
? "[MCP] Godot MCP Pro MINIMAL started (35 tools, stdio transport)"
: THREED_MODE
? "[MCP] Godot MCP Pro 3D started (100 tools, stdio transport)"
: LITE_MODE
? "[MCP] Godot MCP Pro LITE started (81 tools, stdio transport)"
: "[MCP] Godot MCP Pro started (stdio transport)";
console.error(modeLabel);
}
}
main().catch((err) => {
console.error("[MCP] Fatal error:", err);
process.exit(1);
});
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* godot-mcp-setup — Setup and management CLI for Godot MCP Pro
*
* Commands:
* install Install dependencies and build the server
* check-update Check if a newer version is available on GitHub
* configure Auto-detect AI client and generate MCP config
* doctor Diagnose environment (Node.js, npm, build status)
*/
export {};
//# sourceMappingURL=setup.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../src/setup.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}

View File

@@ -0,0 +1,273 @@
#!/usr/bin/env node
/**
* godot-mcp-setup — Setup and management CLI for Godot MCP Pro
*
* Commands:
* install Install dependencies and build the server
* check-update Check if a newer version is available on GitHub
* configure Auto-detect AI client and generate MCP config
* doctor Diagnose environment (Node.js, npm, build status)
*/
import { execSync } from "child_process";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import { resolve, dirname, join } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Server root is one level up from build/
const SERVER_DIR = resolve(__dirname, "..");
const PACKAGE_JSON = join(SERVER_DIR, "package.json");
const BUILD_INDEX = join(SERVER_DIR, "build", "index.js");
const GITHUB_REPO = "youichi-uda/godot-mcp-pro";
// ─── Utilities ────────────────────────────────────────────────
function getVersion() {
try {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf-8"));
return pkg.version || "unknown";
}
catch {
return "unknown";
}
}
function run(cmd, cwd) {
try {
return execSync(cmd, {
cwd: cwd || SERVER_DIR,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
}
catch (err) {
return err.stderr?.trim() || err.message || "command failed";
}
}
function check(label, ok, detail) {
const icon = ok ? "✓" : "✗";
const line = detail ? `${label}: ${detail}` : label;
console.log(` ${icon} ${line}`);
}
/** Compare semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal. */
function compareSemver(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0)
return diff;
}
return 0;
}
// ─── Commands ─────────────────────────────────────────────────
async function cmdInstall() {
console.log("Installing Godot MCP Pro server...\n");
console.log("[1/2] Installing dependencies...");
try {
execSync("npm install", { cwd: SERVER_DIR, stdio: "inherit" });
}
catch {
console.error("\nFailed to install dependencies. Make sure npm is available.");
process.exit(1);
}
console.log("\n[2/2] Building server...");
try {
execSync("npm run build", { cwd: SERVER_DIR, stdio: "inherit" });
}
catch {
console.error("\nBuild failed. Check for TypeScript errors above.");
process.exit(1);
}
console.log(`\nDone! Server built at: ${BUILD_INDEX}`);
console.log(`Version: ${getVersion()}`);
console.log("\nNext step: Run 'node build/setup.js configure' to set up your AI client.");
}
async function cmdCheckUpdate() {
const current = getVersion();
console.log(`Current version: ${current}\n`);
console.log(`Checking GitHub releases for ${GITHUB_REPO}...`);
try {
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { headers: { "User-Agent": "godot-mcp-pro-setup" } });
if (!res.ok) {
if (res.status === 404) {
console.log("No releases found on GitHub.");
return;
}
console.error(`GitHub API error: ${res.status} ${res.statusText}`);
return;
}
const data = (await res.json());
const latest = data.tag_name.replace(/^v/, "");
if (compareSemver(latest, current) > 0) {
console.log(`\nUpdate available: v${latest} (current: v${current})`);
console.log(`Download: ${data.html_url}`);
console.log("\nTo update: download the new version, replace server/src/, and run 'node build/setup.js install'");
}
else {
console.log(`\nUp to date! (${current})`);
}
}
catch (err) {
console.error(`Failed to check for updates: ${err.message}`);
}
}
async function cmdConfigure() {
const serverPath = resolve(BUILD_INDEX).replace(/\\/g, "/");
if (!existsSync(BUILD_INDEX)) {
console.error("Server not built yet. Run 'node build/setup.js install' first.");
process.exit(1);
}
console.log("Detecting AI clients...\n");
// Detect available clients by checking config file locations
const home = process.env.HOME || process.env.USERPROFILE || "";
const cwd = process.cwd();
const candidates = [
{
name: "Claude Code (project)",
configPath: join(cwd, ".mcp.json"),
configKey: "godot-mcp-pro",
},
{
name: "Cursor (project)",
configPath: join(cwd, ".cursor", "mcp.json"),
configKey: "godot-mcp-pro",
},
{
name: "Windsurf (project)",
configPath: join(cwd, ".windsurf", "mcp.json"),
configKey: "godot-mcp-pro",
},
{
name: "Claude Desktop",
configPath: join(home, process.platform === "win32"
? "AppData/Roaming/Claude/claude_desktop_config.json"
: process.platform === "darwin"
? "Library/Application Support/Claude/claude_desktop_config.json"
: ".config/claude/claude_desktop_config.json"),
configKey: "godot-mcp-pro",
},
];
// Find existing configs
const existing = candidates.filter((c) => existsSync(c.configPath));
const missing = candidates.filter((c) => !existsSync(c.configPath));
if (existing.length > 0) {
console.log("Found existing configs:");
for (const c of existing) {
console.log(`${c.name}: ${c.configPath}`);
}
}
// Default: create .mcp.json in cwd (Claude Code)
const target = candidates[0]; // Claude Code project-level
// No GODOT_MCP_PORT env: lets the server auto-scan 6505-6509 so multiple
// Claude Code sessions can each grab a free port. Pinning a single port
// here would force every session to collide on 6505.
const entry = {
command: "node",
args: [serverPath],
};
let config;
if (existsSync(target.configPath)) {
try {
config = JSON.parse(readFileSync(target.configPath, "utf-8"));
if (!config.mcpServers)
config.mcpServers = {};
}
catch {
config = { mcpServers: {} };
}
}
else {
config = { mcpServers: {} };
}
if (config.mcpServers[target.configKey]) {
console.log(`\n${target.name} already configured in ${target.configPath}`);
console.log("Updating server path...");
}
config.mcpServers[target.configKey] = entry;
const dir = dirname(target.configPath);
if (!existsSync(dir))
mkdirSync(dir, { recursive: true });
writeFileSync(target.configPath, JSON.stringify(config, null, 2) + "\n");
console.log(`\nWrote config to: ${target.configPath}`);
console.log(`Server path: ${serverPath}`);
console.log("\nYou're all set! Start your AI assistant to begin using Godot MCP Pro.");
}
function cmdDoctor() {
console.log("Godot MCP Pro — Environment Check\n");
// Node.js
const nodeVer = run("node --version");
const nodeOk = nodeVer.startsWith("v") && parseInt(nodeVer.slice(1)) >= 18;
check("Node.js", nodeOk, nodeVer);
// npm
const npmVer = run("npm --version");
const npmOk = !npmVer.includes("not found") && !npmVer.includes("failed");
check("npm", npmOk, npmVer);
// Dependencies installed
const nodeModules = existsSync(join(SERVER_DIR, "node_modules"));
check("Dependencies installed", nodeModules);
// Server built
const built = existsSync(BUILD_INDEX);
check("Server built", built, built ? BUILD_INDEX : "run 'node build/setup.js install'");
// Version
console.log(`\n Version: ${getVersion()}`);
// Overall
const allOk = nodeOk && npmOk && nodeModules && built;
console.log(allOk ? "\nAll good!" : "\nSome issues found. Fix them above.");
if (!allOk)
process.exit(1);
}
// ─── Main ─────────────────────────────────────────────────────
function showHelp() {
console.log(`godot-mcp-setup — Setup and management for Godot MCP Pro
Usage: node build/setup.js <command>
Commands:
install Install dependencies and build the server
check-update Check if a newer version is available on GitHub
configure Auto-detect AI client and generate .mcp.json config
doctor Check Node.js, npm, and build status
Options:
--help Show this help
--version Show current version
Examples:
node build/setup.js install
node build/setup.js doctor
node build/setup.js configure
node build/setup.js check-update`);
}
async function main() {
const args = process.argv.slice(2);
const cmd = args[0];
if (!cmd || cmd === "--help" || cmd === "-h") {
showHelp();
process.exit(0);
}
if (cmd === "--version" || cmd === "-v") {
console.log(getVersion());
process.exit(0);
}
switch (cmd) {
case "install":
await cmdInstall();
break;
case "check-update":
await cmdCheckUpdate();
break;
case "configure":
await cmdConfigure();
break;
case "doctor":
cmdDoctor();
break;
default:
console.error(`Unknown command: ${cmd}`);
showHelp();
process.exit(1);
}
}
main().catch((err) => {
console.error("Fatal:", err.message);
process.exit(1);
});
//# sourceMappingURL=setup.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { GodotConnection } from "../godot-connection.js";
export declare function registerAnalysisTools(server: McpServer, godot: GodotConnection): void;
//# sourceMappingURL=analysis-tools.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"analysis-tools.d.ts","sourceRoot":"","sources":["../../src/tools/analysis-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAmGN"}

View File

@@ -0,0 +1,74 @@
import { z } from "zod";
import { formatErrorForMcp } from "../utils/errors.js";
export function registerAnalysisTools(server, godot) {
server.tool("find_unused_resources", "Scan the project for resource files (.tres, .tscn, .png, .wav, .ogg, .ttf, .gdshader, etc.) that are not referenced by any .tscn, .gd, or .tres file. Useful for cleaning up unused assets.", {
path: z.string().optional().describe("Root path to scan (default: res://)"),
include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"),
}, async (params) => {
try {
const result = await godot.sendCommand("find_unused_resources", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("analyze_signal_flow", "Map all signal connections in the currently edited scene. Returns a graph-like structure showing which nodes emit which signals and which nodes receive them.", {}, async () => {
try {
const result = await godot.sendCommand("analyze_signal_flow");
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("analyze_scene_complexity", "Analyze a scene's complexity: total node count, max nesting depth, nodes grouped by type, attached scripts, and potential issues (too many nodes, deep nesting).", {
path: z.string().optional().describe("Scene path to analyze (default: currently edited scene)"),
}, async (params) => {
try {
const result = await godot.sendCommand("analyze_scene_complexity", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("find_script_references", "Find all places where a given script path, class_name, or resource path is referenced across the project. Searches .tscn, .gd, and .tres files.", {
query: z.string().describe("The script path, class_name, or resource path to search for (e.g. 'res://scripts/player.gd', 'PlayerController', 'res://assets/icon.png')"),
path: z.string().optional().describe("Root path to search (default: res://)"),
include_addons: z.boolean().optional().describe("Include addons/ directory in search (default: false)"),
}, async (params) => {
try {
const result = await godot.sendCommand("find_script_references", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("detect_circular_dependencies", "Check for circular scene dependencies where Scene A instances Scene B which instances Scene A (directly or indirectly). Walks all .tscn files and builds a dependency graph.", {
path: z.string().optional().describe("Root path to scan (default: res://)"),
include_addons: z.boolean().optional().describe("Include addons/ directory in scan (default: false)"),
}, async (params) => {
try {
const result = await godot.sendCommand("detect_circular_dependencies", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("get_project_statistics", "Get overall project statistics: file counts by extension, total script lines, scene count, resource count, autoload list, and enabled plugins.", {
path: z.string().optional().describe("Root path to scan (default: res://)"),
include_addons: z.boolean().optional().describe("Include addons/ directory in statistics (default: false)"),
}, async (params) => {
try {
const result = await godot.sendCommand("get_project_statistics", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
}
//# sourceMappingURL=analysis-tools.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"analysis-tools.js","sourceRoot":"","sources":["../../src/tools/analysis-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,qBAAqB,CACnC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,uBAAuB,EACvB,6LAA6L,EAC7L;QACE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QAC3E,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oDAAoD,CAAC;KACtG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,uBAAuB,EAAE,MAAM,CAAC,CAAC;YACxE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,qBAAqB,EACrB,+JAA+J,EAC/J,EAAE,EACF,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,qBAAqB,CAAC,CAAC;YAC9D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,0BAA0B,EAC1B,kKAAkK,EAClK;QACE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,yDAAyD,CAAC;KAChG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,0BAA0B,EAAE,MAAM,CAAC,CAAC;YAC3E,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,wBAAwB,EACxB,iJAAiJ,EACjJ;QACE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,2IAA2I,CAAC;QACvK,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uCAAuC,CAAC;QAC7E,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;KACxG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC;YACzE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,8BAA8B,EAC9B,8KAA8K,EAC9K;QACE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QAC3E,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oDAAoD,CAAC;KACtG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,8BAA8B,EAAE,MAAM,CAAC,CAAC;YAC/E,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,wBAAwB,EACxB,gJAAgJ,EAChJ;QACE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qCAAqC,CAAC;QAC3E,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0DAA0D,CAAC;KAC5G,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC;YACzE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}

View File

@@ -0,0 +1,4 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { GodotConnection } from "../godot-connection.js";
export declare function registerAndroidTools(server: McpServer, godot: GodotConnection): void;
//# sourceMappingURL=android-tools.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"android-tools.d.ts","sourceRoot":"","sources":["../../src/tools/android-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CAoDN"}

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { formatErrorForMcp } from "../utils/errors.js";
export function registerAndroidTools(server, godot) {
server.tool("list_android_devices", "List Android devices visible to adb (parses 'adb devices -l'). Uses the path configured in Editor Settings > Export > Android > Adb, falls back to 'adb' on PATH.", {}, async () => {
try {
const result = await godot.sendCommand("list_android_devices");
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("get_android_preset_info", "Read metadata (package name, export path, runnable flag) from an Android export preset in export_presets.cfg. If no preset is specified, returns the first Android preset.", {
preset_name: z.string().optional().describe("Preset name as shown in Project > Export"),
preset_index: z.number().optional().describe("Preset index (alternative to name)"),
}, async (params) => {
try {
const result = await godot.sendCommand("get_android_preset_info", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("deploy_to_android", "Export APK via Godot CLI, install it on a connected Android device via adb, and optionally launch the main activity. Equivalent to Godot's Remote Deploy button. Requires a configured Android export preset and adb on PATH (or set in Editor Settings). This call is synchronous and may take tens of seconds to complete.", {
preset_name: z.string().optional().describe("Android export preset name (defaults to first Android preset)"),
preset_index: z.number().optional().describe("Preset index (alternative to name)"),
device_serial: z.string().optional().describe("adb device serial (omit to use default device)"),
debug: z.boolean().optional().describe("Debug export (default: true)"),
launch: z.boolean().optional().describe("Launch the app after install (default: true)"),
skip_export: z.boolean().optional().describe("Skip the export step and install the existing APK at the preset's export_path (default: false)"),
}, async (params) => {
try {
const result = await godot.sendCommand("deploy_to_android", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
}
//# sourceMappingURL=android-tools.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"android-tools.js","sourceRoot":"","sources":["../../src/tools/android-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,oBAAoB,CAClC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,sBAAsB,EACtB,mKAAmK,EACnK,EAAE,EACF,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,sBAAsB,CAAC,CAAC;YAC/D,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,yBAAyB,EACzB,4KAA4K,EAC5K;QACE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,0CAA0C,CAAC;QACvF,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;KACnF,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,yBAAyB,EAAE,MAAM,CAAC,CAAC;YAC1E,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,mBAAmB,EACnB,8TAA8T,EAC9T;QACE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+DAA+D,CAAC;QAC5G,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;QAClF,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gDAAgD,CAAC;QAC/F,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8BAA8B,CAAC;QACtE,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,8CAA8C,CAAC;QACvF,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gGAAgG,CAAC;KAC/I,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;YACpE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}

View File

@@ -0,0 +1,4 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { GodotConnection } from "../godot-connection.js";
export declare function registerAnimationTools(server: McpServer, godot: GodotConnection): void;
//# sourceMappingURL=animation-tools.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"animation-tools.d.ts","sourceRoot":"","sources":["../../src/tools/animation-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA8GN"}

View File

@@ -0,0 +1,85 @@
import { z } from "zod";
import { formatErrorForMcp } from "../utils/errors.js";
export function registerAnimationTools(server, godot) {
server.tool("list_animations", "List all animations in an AnimationPlayer node", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
}, async (params) => {
try {
const result = await godot.sendCommand("list_animations", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("create_animation", "Create a new animation in an AnimationPlayer", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
name: z.string().describe("Name for the new animation"),
length: z.number().optional().describe("Animation length in seconds (default: 1.0)"),
loop_mode: z.number().optional().describe("Loop mode: 0=none, 1=linear, 2=pingpong (default: 0)"),
}, async (params) => {
try {
const result = await godot.sendCommand("create_animation", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("add_animation_track", "Add a track to an animation (value, position, rotation, scale, method, bezier)", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
animation: z.string().describe("Animation name"),
track_path: z.string().describe("Node path and property for the track (e.g. 'Sprite2D:position')"),
track_type: z.string().optional().describe("Track type: value, position_2d, rotation_2d, scale_2d, method, bezier, blend_shape (default: value)"),
update_mode: z.string().optional().describe("Update mode for value tracks: continuous, discrete, capture"),
}, async (params) => {
try {
const result = await godot.sendCommand("add_animation_track", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("set_animation_keyframe", "Insert a keyframe into an animation track", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
animation: z.string().describe("Animation name"),
track_index: z.number().describe("Track index"),
time: z.number().describe("Time position in seconds"),
value: z.union([z.string(), z.number(), z.boolean()]).describe("Keyframe value. Strings auto-parsed for Vector2, Color, etc."),
easing: z.number().optional().describe("Easing/transition value. 1.0=linear, <1.0=ease-in, >1.0=ease-out. Use negative for in-out variants. (default: 1.0)"),
}, async (params) => {
try {
const result = await godot.sendCommand("set_animation_keyframe", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("get_animation_info", "Get detailed info about an animation including all tracks and keyframes", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
animation: z.string().describe("Animation name"),
}, async (params) => {
try {
const result = await godot.sendCommand("get_animation_info", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("remove_animation", "Remove an animation from an AnimationPlayer", {
node_path: z.string().describe("Path to the AnimationPlayer node"),
name: z.string().describe("Name of the animation to remove"),
}, async (params) => {
try {
const result = await godot.sendCommand("remove_animation", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
}
//# sourceMappingURL=animation-tools.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"animation-tools.js","sourceRoot":"","sources":["../../src/tools/animation-tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,UAAU,sBAAsB,CACpC,MAAiB,EACjB,KAAsB;IAEtB,MAAM,CAAC,IAAI,CACT,iBAAiB,EACjB,gDAAgD,EAChD;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;KACnE,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;YAClE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,kBAAkB,EAClB,8CAA8C,EAC9C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,4BAA4B,CAAC;QACvD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4CAA4C,CAAC;QACpF,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sDAAsD,CAAC;KAClG,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;YACnE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,qBAAqB,EACrB,gFAAgF,EAChF;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChD,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,iEAAiE,CAAC;QAClG,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,qGAAqG,CAAC;QACjJ,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,6DAA6D,CAAC;KAC3G,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;YACtE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,wBAAwB,EACxB,2CAA2C,EAC3C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;QAChD,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC/C,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC;QACrD,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,8DAA8D,CAAC;QAC9H,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oHAAoH,CAAC;KAC7J,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC;YACzE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,oBAAoB,EACpB,yEAAyE,EACzE;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,gBAAgB,CAAC;KACjD,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC;YACrE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;IAEF,MAAM,CAAC,IAAI,CACT,kBAAkB,EAClB,6CAA6C,EAC7C;QACE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC;QAClE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;KAC7D,EACD,KAAK,EAAE,MAAM,EAAE,EAAE;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,WAAW,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;YACnE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACpF,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}

View File

@@ -0,0 +1,4 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { GodotConnection } from "../godot-connection.js";
export declare function registerAnimationTreeTools(server: McpServer, godot: GodotConnection): void;
//# sourceMappingURL=animation-tree-tools.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"animation-tree-tools.d.ts","sourceRoot":"","sources":["../../src/tools/animation-tree-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGzD,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,eAAe,GACrB,IAAI,CA+JN"}

View File

@@ -0,0 +1,124 @@
import { z } from "zod";
import { formatErrorForMcp } from "../utils/errors.js";
export function registerAnimationTreeTools(server, godot) {
server.tool("create_animation_tree", "Create an AnimationTree node with an AnimationNodeStateMachine as root, optionally linked to an AnimationPlayer", {
node_path: z.string().describe("Path to the parent node where the AnimationTree will be added"),
anim_player: z.string().optional().describe("Relative path from the AnimationTree to the AnimationPlayer (e.g. '../AnimationPlayer')"),
name: z.string().optional().describe("Name for the AnimationTree node (default: 'AnimationTree')"),
}, async (params) => {
try {
const result = await godot.sendCommand("create_animation_tree", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("get_animation_tree_structure", "Read the full structure of an AnimationTree including all states, transitions, and blend tree nodes", {
node_path: z.string().describe("Path to the AnimationTree node"),
}, async (params) => {
try {
const result = await godot.sendCommand("get_animation_tree_structure", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("add_state_machine_state", "Add a state to an AnimationNodeStateMachine (animation clip, blend tree, or nested state machine)", {
node_path: z.string().describe("Path to the AnimationTree node"),
state_name: z.string().describe("Name for the new state"),
state_type: z.enum(["animation", "blend_tree", "state_machine"]).optional().describe("Type of state: 'animation' (default), 'blend_tree', or 'state_machine'"),
animation: z.string().optional().describe("Animation name to play (only for state_type='animation')"),
state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine (e.g. 'Run/SubState'). Empty or omit for root."),
position_x: z.number().optional().describe("X position in the graph editor (default: 0)"),
position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"),
}, async (params) => {
try {
const result = await godot.sendCommand("add_state_machine_state", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("remove_state_machine_state", "Remove a state from an AnimationNodeStateMachine (also removes connected transitions)", {
node_path: z.string().describe("Path to the AnimationTree node"),
state_name: z.string().describe("Name of the state to remove"),
state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."),
}, async (params) => {
try {
const result = await godot.sendCommand("remove_state_machine_state", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("add_state_machine_transition", "Add a transition between two states in an AnimationNodeStateMachine with configurable switch mode, advance mode, and expression conditions", {
node_path: z.string().describe("Path to the AnimationTree node"),
from_state: z.string().describe("Source state name (use 'Start' for the entry point)"),
to_state: z.string().describe("Destination state name (use 'End' for the exit point)"),
switch_mode: z.enum(["at_end", "immediate", "sync"]).optional().describe("When to switch: 'at_end' (wait for animation), 'immediate' (default), 'sync'"),
advance_mode: z.enum(["disabled", "enabled", "auto"]).optional().describe("How to advance: 'disabled', 'enabled' (default, uses travel), 'auto' (automatic)"),
advance_expression: z.string().optional().describe("GDScript expression that triggers this transition (e.g. 'is_running')"),
xfade_time: z.number().optional().describe("Cross-fade time in seconds"),
state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."),
}, async (params) => {
try {
const result = await godot.sendCommand("add_state_machine_transition", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("remove_state_machine_transition", "Remove a transition between two states in an AnimationNodeStateMachine", {
node_path: z.string().describe("Path to the AnimationTree node"),
from_state: z.string().describe("Source state name"),
to_state: z.string().describe("Destination state name"),
state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."),
}, async (params) => {
try {
const result = await godot.sendCommand("remove_state_machine_transition", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("set_blend_tree_node", "Add or replace a node inside an AnimationNodeBlendTree state (Add2, Blend2, TimeScale, Animation, etc.) with optional connection", {
node_path: z.string().describe("Path to the AnimationTree node"),
blend_tree_state: z.string().describe("Name of the BlendTree state in the state machine"),
bt_node_name: z.string().describe("Name for the node inside the BlendTree"),
bt_node_type: z.enum(["Animation", "Add2", "Blend2", "Add3", "Blend3", "TimeScale", "TimeSeek", "Transition", "OneShot", "Sub2"]).describe("Type of BlendTree node to create"),
animation: z.string().optional().describe("Animation name (only for bt_node_type='Animation')"),
connect_to: z.string().optional().describe("Name of another BlendTree node to connect this node's output to"),
connect_port: z.number().optional().describe("Input port index on the target node (default: 0)"),
state_machine_path: z.string().optional().describe("Slash-separated path to a nested state machine. Empty or omit for root."),
position_x: z.number().optional().describe("X position in the graph editor (default: 0)"),
position_y: z.number().optional().describe("Y position in the graph editor (default: 0)"),
}, async (params) => {
try {
const result = await godot.sendCommand("set_blend_tree_node", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
server.tool("set_tree_parameter", "Set an AnimationTree parameter value (conditions, blend amounts, time scale, etc.)", {
node_path: z.string().describe("Path to the AnimationTree node"),
parameter: z.string().describe("Parameter path (e.g. 'conditions/is_running', 'Blend2/blend_amount'). 'parameters/' prefix is auto-added if missing."),
value: z.union([z.string(), z.number(), z.boolean()]).describe("Parameter value. Strings are auto-parsed for Vector2, Color, etc."),
}, async (params) => {
try {
const result = await godot.sendCommand("set_tree_parameter", params);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
catch (e) {
return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true };
}
});
}
//# sourceMappingURL=animation-tree-tools.js.map

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More