Compare commits

...
25 Commits
Author SHA1 Message Date
joywayer d9b19b404a 文档新增说明restart 2026-08-07 11:47:52 +08:00
joywayer 3fd2c89020 fix(vps-xray): 修复定时重启的四类静默失效并补齐安全不变式测试
Critical-1 timer 时区:[Timer] 段没有 TimeZone= 这个 key,systemd 只会记一条
"Unknown key name" 然后按系统本地时区解析 OnCalendar。VPS 出厂多为 UTC,
用户设「04:00 Asia/Shanghai」会在北京时间 12:00 触发且毫无提示。
改为把时区写进日历表达式本身:OnCalendar=*-*-* HH:MM:00 <TZ>(systemd >= 242)。

Critical-2 前导零:^[0-9]+$ 放行 08/09/010,该值原样写进 guard 的 DAYS=,
而 guard 里是算术展开——010 被当八进制解析成 8 天,08/09 直接报
"value too great for base",使间隔判断求值为假,落进重启分支变成每天重启。
校验通过后统一 $((10#$days)) 归一化。

Important-3 状态文件写入失败:mkdir 与写时间戳都不检查返回值,/var 写满或
只读时写入失败却照样重启,「先写时间戳」的保护完全失效,退化成每天硬重启且
无限持续。改为 fail-safe:写不进去就报错并跳过本轮。
update_env_field 的三处写入同样补上返回值检查,调用方改在条件位置。

Important-4 未来时间戳:RTC 错乱或 NTP 大幅向后校正时会写下未来的时间戳,
时钟校回后 now-last 恒为负,此后永远跳过且无自愈路径。与「内容非法按 0 处理」
同策略,读到 last > now 时按 0 处理。

Important-5 静默中止:configure_restart_timer 被 harden_system、main 一路裸调用,
set -e 下 systemctl enable 失败会让部署在「8/8 加固」之后无声退出——
start_service / save_env / print_result 全不执行,config.json 已换、防火墙已改,
但 xray 没重启、.env 没更新、链接没打印。三处 systemctl 全部改到条件位置,
enable 不再吞 stderr(规格本就要求靠 systemctl 报错暴露非法时区)。

Important-6 NEXT 语义:list-timers 的 NEXT 是 timer 的「下次唤醒」,每天一次,
不是下次重启时间。新增 print_next_restart,--restart 输出里直接算出并显示
「下次实际重启时间」,README 同步说明差异。

Important-7 安全不变式测试:configure_restart_timer 与 apply_restart_only 原本
零覆盖,「不重启 xray、不碰 vless 链接」这个最重要的承诺恰恰没有回归保护。
补上启用/关闭两个方向的断言,以及 .env 逐字段 diff 与 systemctl 调用日志断言。

Minor-9 README 补充取舍:完整部署的 systemctl restart xray 不更新状态文件。
Minor-11 save_env 注释「这两项」→「这几项」。

测试 61 → 101 项,全部通过。
2026-08-07 09:37:46 +08:00
joywayer 0dc80ff144 fix(vps-xray): 删除旧的每日重启小节,补充新小节中的缺失理由;更新uninstall日志 2026-08-06 17:17:01 +08:00
joywayer 34f698e5fb docs(vps-xray): 定时重启配置说明;uninstall 清理 guard 与状态文件 2026-08-06 17:13:12 +08:00
joywayer 9f1e293d4c feat(vps-xray): 新增 --restart 轻量入口
只改定时重启计划并立即生效,不重装 Xray、不重写配置、不重启服务,
与 --mode/--show/--redetect 互斥。
顺带修正非法参数以退出码 0 结束的问题。
2026-08-06 17:02:15 +08:00
joywayer 682b79852d refactor(vps-xray): 完整部署改用 configure_restart_timer
harden_system 与 start_service 里的旧 DAILY_RESTART 分支删除,
save_env 改写三个新字段。
2026-08-06 16:49:57 +08:00
joywayer 22d1aa6a93 test(vps-xray): 精确锚定 grep 匹配、补充容错验证断言 2026-08-06 16:39:23 +08:00
joywayer 354ac8a58c test(vps-xray): 覆盖 guard 脚本的间隔判断、容差与状态文件容错 2026-08-06 16:25:53 +08:00
joywayer d19d337bf2 feat(vps-xray): 生成 timer/service/guard 三个单元文件
timer 每天唤醒,N 天间隔判断交给 guard 脚本,
绕开 OnCalendar 无法表达任意 N 天间隔的限制。
2026-08-06 16:15:03 +08:00
joywayer dfcaf7e4d0 fix(vps-xray): update_env_field 追加时处理缺尾换行的文件,防止字段黏连 2026-08-06 16:08:42 +08:00
joywayer 39b0aee2c9 feat(vps-xray): 新增 update_env_field 就地更新 .env 单个字段 2026-08-06 16:02:22 +08:00
joywayer 920774d7d7 fix(vps-xray): 添加过渡兼容赋值 DAILY_RESTART
harden_system / start_service / save_env 仍引用 DAILY_RESTART,
下游清理要到后续任务进行。本任务在 resolve_restart_config 之后
添加兼容赋值,确保 set -u 下脚本完整可运行。
2026-08-06 15:57:07 +08:00
joywayer 29236e8e1c feat(vps-xray): 重启计划配置解析与旧字段迁移
新增 normalize_restart_time / resolve_restart_config,
XRAY_RESTART_EVERY_DAYS 缺失时才回退读 XRAY_DAILY_RESTART。
附单元测试,不需要 root 与 systemd。
2026-08-06 15:48:43 +08:00
joywayer 938df26cbb www.apple.com 为伪装地址,前端警告消失 2026-08-06 13:02:46 +08:00
joywayer e8cc0162af 升级gitea 2026-08-05 21:08:48 +08:00
joywayer 5195986fc4 vps部署优化 2026-08-05 17:12:18 +08:00
joywayer 2afc2cade6 优化部署,增加lisa dmit 2026-08-05 09:51:12 +08:00
joywayer 81928c6d33 Merge branch 'master' of ssh://git.joywaygames.cn:2222/joywaygamess/server-deploy 2026-08-05 08:10:38 +08:00
joywayer 56343db806 新增 vps 3 2026-08-04 19:46:48 +08:00
joywayer 1bfd657f99 vaultwarden的升级和部署修改为1.37版本 2026-07-29 16:41:58 +08:00
joywayer 99e9ec1434 Merge branch 'master' of ssh://git.joywaygames.cn:2222/joywaygamess/server-deploy
# Conflicts:
#	vps-xray/vps-xray-optimized-info.md   resolved by theirs version
2026-07-24 15:53:03 +08:00
joywayer 1b19054da1 删除 windows dev stack 2026-07-24 15:52:44 +08:00
joywayer 353d3940a9 添加clash yaml 2026-06-24 20:29:06 +08:00
joywayer bffa517ed4 Merge branch 'master' of ssh://git.joywaygames.cn:2222/joywaygamess/server-deploy 2026-06-24 18:33:28 +08:00
joywayer 80ba4b046c 优化vps部署 2026-06-24 18:33:11 +08:00
517 changed files with 5892 additions and 69481 deletions
-27
View File
@@ -1,27 +0,0 @@
<!-- rtk-instructions v2 -->
# RTK — Token-Optimized CLI
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
## Rule
Always prefix shell commands with `rtk`:
```bash
# Instead of: Use:
git status rtk git status
git log -10 rtk git log -10
cargo test rtk cargo test
docker ps rtk docker ps
kubectl get pods rtk kubectl pods
```
## Meta commands (use directly)
```bash
rtk gain # Token savings dashboard
rtk gain --history # Per-command savings history
rtk discover # Find missed rtk opportunities
rtk proxy <cmd> # Run raw (no filtering) but track usage
```
<!-- /rtk-instructions -->
-12
View File
@@ -1,12 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "rtk hook copilot",
"cwd": ".",
"timeout": 5
}
]
}
}
+20
View File
@@ -0,0 +1,20 @@
# 环境变量 / 凭据(.env.example 模板除外)
.env
*.env
!.env.example
!*.env.example
# vps-xray:部署脚本生成的真实节点配置,含 UUID / 私钥 / 服务器 IP
# vps-xray/client-config/*-info*.md
# vps-xray/client-config/*.yaml
# vps-xray/client-config/*.yml
# 备份产物
*.bak
*.bak.*
backups/
# 系统 / 编辑器
.DS_Store
Thumbs.db
*.swp
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
# Xray 定时重启:可配置的「每 N 天 + 固定时间点」
日期:2026-08-06
涉及文件:`vps-xray/deploy.sh``vps-xray/.env.example``vps-xray/uninstall.sh``vps-xray/README.md`
## 背景
当前 `deploy.sh` 只有一个布尔开关 `XRAY_DAILY_RESTART`,为真时创建 `OnCalendar=*-*-* 04:00:00` 的每日重启 timer,为假时删除该 timer。默认关闭,理由是崩溃恢复已由 `Restart=always` + `RestartSec=3` 覆盖(秒级),而每日硬重启会切断全部活动连接。
这个判断本身没变,但「一天一次」粒度太粗:想要「每 7 天凌晨 4 点重启一次」这种低频维护性重启时,只能在「每天重启」和「完全不重启」之间二选一。
## 目标
1. 支持任意 N 天间隔 + 指定时间点,如「每 7 天 04:00」「每 3 天 05:30」
2. 保持默认关闭,已部署机器重跑脚本时行为不变
3. 提供一个轻量命令行入口,只改重启计划,不触碰 Xray 与现有 vless 链接
## 非目标
- 不做「每月/每季度」等日历语义(真有需要时用户可直接编辑 timer)
- 不做多时间点(如一天重启两次)
- 不改动 `Restart=always` 这一层崩溃恢复逻辑
## 关键约束:systemd 表达不了任意 N 天
`OnCalendar``*-*-1/7 04:00:00` 是「每月的 1、8、15、22、29 号」,**跨月会重置**——29 号到下月 1 号只隔 2~3 天。因此不能直接用 `OnCalendar` 表达「每 N 天」。
`OnUnitActiveSec=7d` 是真实间隔,但无法钉住具体时间点,且会随每次触发缓慢漂移。
本设计采用第三种:**timer 每天到点唤醒,由 service 里的守卫脚本决定这次要不要真的重启。**
## 配置模型
`.env` 三个字段:
```bash
XRAY_RESTART_EVERY_DAYS=0 # 0 或缺失 = 关闭;N = 每 N 天重启一次
XRAY_RESTART_TIME=04:00 # 24 小时制 HH:MM,默认 04:00
XRAY_RESTART_TIMEZONE=Asia/Shanghai # 默认保持现状
```
首次部署默认 `0`(关闭),与当前行为一致。
### 旧字段迁移
只在 `XRAY_RESTART_EVERY_DAYS` **未设置**时才回退去读 `XRAY_DAILY_RESTART`
| 旧值 | 迁移结果 |
|---|---|
| `true` / `yes` / `on` / `1`(大小写不敏感) | `XRAY_RESTART_EVERY_DAYS=1` |
| 其它或缺失 | `XRAY_RESTART_EVERY_DAYS=0` |
`save_env` 之后只写新字段,不再写 `XRAY_DAILY_RESTART``.env` 随重跑自动换代。两个字段同时存在时以新字段为准。
现存四台服务器的 `.env` 都是 `XRAY_DAILY_RESTART=false`,迁移后为 `0`,行为不变。
## 实现架构
```
xray-restart.timer OnCalendar=*-*-* <HH:MM>:00 每天到点唤醒
TimeZone=<tz>
Persistent=true
xray-restart.service Type=oneshot
ExecStart=/usr/local/bin/xray-restart-guard
xray-restart-guard 读 /var/lib/xray/last-restart
距上次不足 N 天 → 打日志后 exit 0
满 N 天 → 写入新时间戳 → systemctl restart xray
```
「每 N 天」因此是**距上次实际重启**的真实间隔,不受跨月重置影响,N=3 与 N=7 同样准确。
### 守卫脚本的两个关键细节
**300 秒容差。** systemd timer 默认 `AccuracySec=1min`,第 N 次触发的实际间隔可能是 `N×86400 - 30s`。严格比较会判定「不满 N 天」从而顺延整整一天,且误差会持续累积。阈值取 `N×86400 - 300` 规避。容差远小于一天,不会造成重复触发。
**先写时间戳,再重启。** `systemctl restart xray` 会中断 guard 所在的 systemd 事务。顺序反了会丢失时间戳记录,导致每天都重启。代价是:重启失败时时间戳仍已更新,本轮被跳过——这比陷入每日重启循环可接受。
### 状态文件
`/var/lib/xray/last-restart`,内容为一个 Unix 时间戳。guard 以 root 运行,读写无权限问题。内容非法(非纯数字)时按 `0` 处理,即立即允许重启。
## 参数校验
在脚本早期(`.env` 载入后)完成,不合法直接报错退出,**不静默回退到默认值**——避免用户以为设了每 7 天、实际在每天重启。
| 字段 | 规则 |
|---|---|
| `XRAY_RESTART_EVERY_DAYS` | `^[0-9]+$` |
| `XRAY_RESTART_TIME` | `^([01]?[0-9]\|2[0-3]):[0-5][0-9]$`,校验通过后**补零归一化**为 `HH:MM` |
| `XRAY_RESTART_TIMEZONE` | 非空即可,交由 systemd 校验并在启动失败时报错 |
时间同时接受 `4:00``04:00`,但写入 `.env` 和 timer 前一律归一化成两位小时(`04:00`),保证 `OnCalendar` 字符串格式统一、`.env` 内容可预测。
## `--restart` 轻量入口
### 用法
```bash
bash deploy.sh --restart 7 04:00 # 每 7 天 04:00
bash deploy.sh --restart 3 # 每 3 天,时间沿用 .env
bash deploy.sh --restart 0 # 关闭
bash deploy.sh --restart # 按 .env 现有值重建 timer
```
### 参数解析
`--restart` 后可跟 0~2 个位置参数,按形态判断,不会误吞后续选项:
- 下一个 token 匹配 `^[0-9]+$` → 取作 N
- 再下一个匹配 `^([01]?[0-9]|2[0-3]):[0-5][0-9]$` → 取作 HH:MM
- 不匹配则不消费,沿用 `.env` 现值
`--restart``--mode` / `--show` / `--redetect` 互斥,同时给出直接报错。
### 执行路径
`main()` 中早退,与 `--show` 同一位置:
1. 校验 N 与 HH:MM,时间归一化为 `HH:MM`
2. 就地更新 `.env`:命令行给了 N 就写 `XRAY_RESTART_EVERY_DAYS`,给了时间才写 `XRAY_RESTART_TIME`;两者都没给则不改 `.env`,只按现值重建 timer。字段不存在时追加(`TIME` 追加时用默认值 `04:00`
3. 调用 `configure_restart_timer()`
4. `systemctl daemon-reload`,按需 enable / disable timer
5. 打印结果与 `systemctl list-timers xray-restart.timer`
### 明确不做的事
不装依赖、不升级 Xray、不生成密钥、不选伪装目标、不重写 `config.json`、不动防火墙与 sysctl、**不重启 xray**。因此 vless 链接与现有连接均不受影响。这些约束会以注释形式写在函数头部。
### 前置检查
- `.env` 不存在 → 报错退出(无法确认这是已部署的机器)
- xray 主单元不存在 → 报错退出(避免在未部署的机器上留下孤儿 timer)
## 代码组织
**timer / service / guard 的创建逻辑抽成独立函数 `configure_restart_timer()`**,由 `harden_system()``--restart` 路径共用。两条路径各写一份实现迟早会漂移成不一致。
`.env` 更新走两条不同路径,这是有意的:
- 完整部署走既有的 `save_env()``cat >` 全量重写)
- `--restart``sed` 就地替换 —— 轻量入口只应改自己那两行,不该抹掉用户在 `.env` 里加的注释和自定义字段
## 关闭与清理
`XRAY_RESTART_EVERY_DAYS=0` 时:
- `systemctl disable --now xray-restart.timer`
- 删除 `xray-restart.timer``xray-restart.service``/usr/local/bin/xray-restart-guard`
- **保留** `/var/lib/xray/last-restart`
保留状态文件的理由:以后重新启用时,若距上次已超过 N 天则首次触发即重启,符合直觉;若刚重启过则不会立刻再来一次。
`uninstall.sh` 需补上 guard 脚本与状态文件的清理(当前只删了 timer 和 service)。
## 受影响文件
| 文件 | 改动 |
|---|---|
| `vps-xray/deploy.sh` | 参数解析、配置校验与迁移、`configure_restart_timer()``--restart` 路径、`save_env``usage` |
| `vps-xray/.env.example` | 三个新字段与说明 |
| `vps-xray/uninstall.sh` | 清理 guard 脚本与状态文件 |
| `vps-xray/README.md` | 定时重启一节 |
## 验证方案
在真实 VPS 上执行,不接受仅靠阅读代码判断:
1. **语法** —— `bash -n deploy.sh`
2. **启用** —— `bash deploy.sh --restart 7 04:00`,确认:`.env` 两字段已更新;`systemctl list-timers xray-restart.timer` 有下次触发时间;xray 主服务的 `ActiveEnterTimestamp` **未变**(证明没重启)
3. **守卫生效** —— 手动连跑 `xray-restart-guard` 两次,第一次应重启并写入时间戳,第二次应打印「跳过」且 xray 未重启
4. **关闭** —— `bash deploy.sh --restart 0`,确认 timer / service / guard 三个文件均被删除,状态文件仍在
5. **迁移** —— 构造一份只含 `XRAY_DAILY_RESTART=true``.env`,跑完整部署,确认得到 `XRAY_RESTART_EVERY_DAYS=1` 且 timer 已建立
6. **校验** —— `bash deploy.sh --restart 7 25:00``--restart abc` 均应报错退出,且不留下任何单元文件
7. **互斥** —— `bash deploy.sh --restart 7 --redetect` 应报错退出
## 已知取舍
- **间隔锚点是「上次实际重启时间」,不是固定日历日期。** 若某次因机器关机错过了触发点,下次开机后的第一个触发点会补上(`Persistent=true`),此后间隔重新从那一刻起算,会缓慢偏移触发日期。这是「真实间隔」语义的必然结果,也是用户选择的语义。
- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常。理由见上文「先写时间戳」。
- **时区错误要到 timer 启动时才暴露**。脚本不预校验时区字符串,靠 `systemctl` 报错——自行维护时区白名单成本高且易过期。
+2
View File
@@ -1,3 +1,5 @@
data/
backups/
.env
.env.bak-*
.upgrade-state
+83 -30
View File
@@ -326,42 +326,93 @@ docker compose up -d # 启动
#### 升级 Gitea
Gitea 以 Docker 容器运行,升级 = 拉取新镜像 + 重启容器。数据库结构变更会在启动时自动迁移
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)
```bash
cd /opt/gitea
# 1. 备份(必须!)
bash backup.sh
# 2. 查看当前版本
curl -s http://127.0.0.1:3000/api/v1/version
# 3. 修改目标版本(编辑 .env 中的 GITEA_IMAGE
# ● 查看最新版本号: https://github.com/go-gitea/gitea/releases
# ● 或访问: https://hub.docker.com/r/gitea/gitea/tags
vi .env
# 修改: GITEA_IMAGE=gitea/gitea:1.25.5 ← 替换为目标版本号
# 4. 拉取新镜像
docker compose pull server
# 5. 重启(自动执行数据库迁移)
docker compose up -d server
# 6. 检查日志确认启动成功
docker compose logs -f server
# 看到 "Starting new Web server: tcp:0.0.0.0:3000" 表示成功
# Ctrl+C 退出日志
# 7. 验证新版本
curl -s http://127.0.0.1:3000/api/v1/version
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash upgrade.sh # 正式升级到 GitHub 最新 release
```
**注意事项:**
- 务必查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
- 不支持版本降级,升级前务必备份
- 跨多个大版本建议逐版本升级(如 1.21 → 1.22 → 1.23
脚本做的事:
| 步骤 | 内容 | 失败时 |
|------|------|--------|
| 1 | 预检:root、依赖命令、MySQL 连通性、数据表数量、磁盘空间 | 直接退出,服务零影响 |
| 2 | 拉取目标镜像 | 直接退出,服务零影响 |
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
| 4 | 停止 Gitea 容器(**MySQL 保持运行**) | 自动拉起原版本 |
| 5 | mysqldump + 打包数据目录 + 校验备份完整性 | 自动拉起原版本 |
| 6 | 切换镜像启动,Gitea 自动执行数据库迁移 | 自动回滚 |
| 7 | 校验镜像 ID / 运行版本 / 接口 / 表行数 / 迁移版本 | 自动回滚 |
常用参数:
```bash
bash upgrade.sh --check # 只检查,零改动
bash upgrade.sh --version 1.26.4 # 升级到指定版本
bash upgrade.sh --full-backup # 连仓库和 LFS 一起备份(小实例推荐)
bash upgrade.sh --timeout 600 # 大库迁移慢,放宽就绪超时(默认 300 秒)
bash upgrade.sh --doctor # 升级后跑一次 gitea doctor 一致性检查
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
bash upgrade.sh --rollback # 回滚到上次升级前的状态
```
**备份范围说明**
默认只打包会被升级影响的部分:`gitea/conf`app.ini)、`gitea/jwt`OAuth2 签名密钥)、
`ssh`SSH host key)、`gitea/indexers``gitea/queues`、头像目录。
仓库(`git/repositories`)、LFS、软件包**默认不打包** —— 它们是内容寻址的追加式存储,
版本迁移只改数据库结构,不会改写这些文件。上百 GB 的实例每次升级都全量打包并不现实。
数据库则**每次都完整 mysqldump**,因为那才是升级真正会动的东西。
小实例想要更保险,加 `--full-backup` 连仓库一起打包。
**回滚**
```bash
bash upgrade.sh --rollback
```
读取 `.upgrade-state` 里记录的备份与镜像锚点,**回灌 SQL 备份** + 恢复配置目录 + 切回旧镜像。
> 回滚会把数据库整体退回到升级前的状态,升级之后新产生的 issue、PR、评论、仓库记录都会丢失。
> 数据目录用的是合并解压,**不会删除任何仓库或 LFS 文件**。
#### 为什么不要手动升级
**一、跨小版本升级不可逆,只切镜像回滚是无效的**
按 [Gitea 官方文档](https://docs.gitea.com/installation/upgrade-from-gitea/),只有
`a.b.x → a.b.y`(补丁级)才保证数据库结构不变、可以来回切。跨小版本(如 1.25 → 1.27)
新版会在启动时改写数据库结构,之后旧版二进制看到新库会直接拒绝启动:
```
Your database (migration version: 286) is for a newer Gitea,
newer database for this old Gitea release (280).
Gitea will exit to keep your database safe and unchanged.
```
所以想退回旧版本,**唯一正确的办法是回灌升级前的 SQL 备份**。手动改 `version` 表是官方
明确警告会丢数据的操作。`upgrade.sh` 的回滚就是按这个路径做的,并且会拒绝执行跨小版本降级。
**二、`docker compose up -d` 不会因为镜像变了就重建容器**
容器处于 stopped 状态时,`up -d` 只是把原容器重新 start,结果是「改了配置但还跑着旧镜像」。
必须 `--force-recreate`。脚本在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,
不匹配直接回滚。
**三、`.env` 里的变量已经被 export 到当前 shell**
部署脚本用 `set -a; source .env` 导出过 `GITEA_IMAGE`,而 docker compose 对环境变量的
优先级高于 `.env` 文件 —— 只改文件不改环境变量,compose 仍会读到旧镜像。
**其它注意事项:**
- 升级前查阅 [Gitea 发版说明](https://github.com/go-gitea/gitea/releases) 了解 Breaking Changes
- 自定义模板与新版不兼容会导致 5xx 或页面错乱,脚本的首页检查能发现这类问题
- MySQL 容器不在 `upgrade.sh` 的升级范围内,需要单独处理(见下)
#### 升级 MySQL
@@ -749,6 +800,8 @@ docker compose exec server gitea admin user change-password -u 管理员用户
├── .env # 运行时配置(自动生成)
├── deploy.sh # 全新服务器一键部署脚本
├── backup.sh # MySQL + 数据备份脚本
├── upgrade.sh # 安全升级脚本(备份 → 校验 → 自动回滚)
├── .upgrade-state # 升级回滚锚点(upgrade.sh 自动生成)
├── uninstall.sh # 完全卸载脚本
├── .gitignore
├── README.md
+1090
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -25,7 +25,11 @@ SENDS_ALLOWED=true
# ===== 镜像配置 =====
# Vaultwarden 镜像
VAULTWARDEN_IMAGE=vaultwarden/server:latest
# 留空或填 latest 时,deploy.sh 会自动查询 GitHub 最新 release 并把具体版本号写回这里。
# 不要长期使用 latest:该标签只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,而且从配置上看不出实际运行的是哪一版。
# 后续升级请用 upgrade.sh,它会自动改写此处的版本号。
VAULTWARDEN_IMAGE=
# ===== 目录与端口 =====
+112 -16
View File
@@ -16,7 +16,7 @@ Bitwarden 兼容的自托管密码管理器,轻量、安全、功能完整。
| 组件 | 版本 | 说明 |
|------|------|------|
| Vaultwarden | latest | Bitwarden 兼容服务端(Rust 实现) |
| Vaultwarden | 固定版本号 | Bitwarden 兼容服务端(Rust 实现),由 `upgrade.sh` 升级 |
| SQLite | 内置 | 轻量数据库,无需额外部署 |
| Nginx | 系统包 | 反向代理 + HTTPSBitwarden 客户端必须 HTTPS |
| Docker | 最新版 | 容器运行环境 |
@@ -35,6 +35,7 @@ vaultwarden/
├── docker-compose.yml # 容器编排
├── .env.example # 配置模板
├── deploy.sh # 一键部署脚本
├── upgrade.sh # 安全升级脚本(带备份、校验、自动回滚)
├── backup.sh # 备份脚本
├── uninstall.sh # 完全卸载脚本
├── nginx/
@@ -97,9 +98,12 @@ bash deploy.sh
cd /opt/vaultwarden
# 编辑 .env,将 SIGNUPS_ALLOWED 改为 false
vi .env
docker compose restart
docker compose up -d # 注意:不是 restart
```
> **不要用 `docker compose restart`**。环境变量是在容器**创建**时注入的,`restart` 只是重启
> 原容器、不会重建,改了 `.env` 也不会生效。必须用 `up -d` 让 compose 检测到配置变更并重建容器。
### 第五步:安装客户端
1. 下载 Bitwarden 客户端:https://bitwarden.com/download/
@@ -224,23 +228,65 @@ docker compose up -d
### 升级
使用 `upgrade.sh` 升级,**不要**手动 `docker compose pull && up -d`(见下方「为什么不要手动升级」)。
```bash
cd /opt/vaultwarden
# 1. 备份数据
bash backup.sh
# 2. 拉取新镜像
docker compose pull
# 3. 重启
docker compose up -d
# 4. 检查运行状态
docker compose ps
docker compose logs --tail 20
bash upgrade.sh --check # 先看看会发生什么,不做任何改动
bash upgrade.sh # 正式升级到 GitHub 最新 release
```
脚本执行流程:
| 步骤 | 动作 | 失败时 |
|------|------|--------|
| 1 | 预检:root、依赖命令、数据目录、磁盘空间 | 直接退出,未动服务 |
| 2 | 拉取新镜像 | 直接退出,**服务零影响**,可安全重试 |
| 3 | 把当前镜像打上 `pre-upgrade-<时间戳>` 标签作为回滚锚点 | — |
| 4 | 停止容器 | 自动拉起原服务 |
| 5 | WAL checkpoint + `integrity_check` + 冷备份整个数据目录 | 自动拉起原服务 |
| 6 | 切换镜像并 `--force-recreate` 重建容器 | 自动回滚 |
| 7 | 校验容器实际镜像 == 目标镜像 | 自动回滚 |
| 8 | 校验 `/alive`、新旧 prelogin 路由、Nginx 反代 | 自动回滚 |
| 9 | 比对升级前后各表行数,任一表减少即判定数据丢失 | 自动回滚 |
常用选项:
```bash
bash upgrade.sh --check # 只检查,零改动
bash upgrade.sh --version 1.37.0 # 升级到指定版本
bash upgrade.sh --yes # 跳过交互确认(自动化场景)
bash upgrade.sh --rollback # 回滚到上次升级前的状态
```
升级完成后请**登录 Web 端确认数据无误**,再清理回滚镜像:
```bash
docker image rm vaultwarden/server:pre-upgrade-<时间戳>
```
### 回滚
```bash
cd /opt/vaultwarden
bash upgrade.sh --rollback
```
回滚会读取 `.upgrade-state` 里记录的备份和镜像锚点,恢复数据目录并切回旧镜像。
> **注意**:回滚会丢弃升级后新增/修改的密码条目。
> 回滚**不会删除**升级后的数据,而是把它改名保留为 `<数据目录>.failed-<时间戳>`,确认无需后再自行删除。
### 为什么不要手动升级
手动 `docker compose pull && docker compose up -d` 有两个坑,都会导致「看起来升级了,其实没有」:
1. **停止状态的容器不会因镜像变更而重建**。如果先 `stop``up -d`compose 只是把原容器重新 `start`,仍然跑旧镜像。必须 `--force-recreate`
2. **环境变量优先级高于 `.env` 文件**。如果当前 shell 里已经 `export``VAULTWARDEN_IMAGE`(比如脚本里 `source .env` 过),改 `.env` 文件无效,compose 依然用旧值。
`upgrade.sh` 处理了这两点,并在启动后强制校验「容器实际运行的镜像 ID == 目标镜像 ID」,不匹配直接回滚。
### 停止 / 启动
```bash
@@ -316,10 +362,24 @@ rm -rf /opt/vaultwarden
注册好所有需要的账号后:
```bash
# .env 中设置
SIGNUPS_ALLOWED=false
cd /opt/vaultwarden
sed -i 's/^SIGNUPS_ALLOWED=.*/SIGNUPS_ALLOWED=false/' .env
docker compose up -d # 必须 up -drestart 不生效
```
验证是否真的关闭了(应返回 `Registration not allowed or user already exists`):
```bash
curl -s -X POST http://127.0.0.1:8080/identity/accounts/register/send-verification-email \
-H 'Content-Type: application/json' \
-d '{"email":"probe@example.invalid","name":"probe"}'
```
> **不要用 `/api/config` 里的 `disableUserRegistration` 判断**。该字段来自
> `is_signup_disabled()`,含义是「是否隐藏 UI 上的注册链接」,而非「注册是否被拒绝」。
> 当 `INVITATIONS_ALLOWED=true` 且未配置 SMTP 时,即使 `SIGNUPS_ALLOWED=false`
> 它也会显示为 `false`(因为管理员邀请的用户仍需走注册流程)。真正的拦截在注册接口里。
### 2. 限制管理面板访问
`nginx/vaultwarden.conf` 中取消注释 `/admin` 的 IP 限制部分,仅允许你的 IP 访问。
@@ -347,6 +407,42 @@ docker compose ps
curl http://127.0.0.1:8080/alive
```
### 客户端登录报错,控制台 404 /identity/accounts/prelogin/password
**症状**:已登录的会话正常,但重新登录失败,浏览器控制台显示:
```
POST https://vault.example.com/identity/accounts/prelogin/password 404 (Not Found)
```
**原因**:服务端版本过旧。Bitwarden 客户端从 v2026.4.0 起改用 `/identity/accounts/prelogin/password`
这个新路由,而 Vaultwarden 在 **1.36.0** 才实现它。客户端会自动更新,服务端不会。
**确认**
```bash
# 看服务端实到底跑的是哪个版本
curl -s https://vault.example.com/api/config | grep -o '"gitHash":"[^"]*"'
# 直接探测新旧两个路由
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"email":"x@example.com"}' https://vault.example.com/identity/accounts/prelogin/password
```
旧路由返回 200、新路由返回 404,即可确诊。
**修复**:升级到 1.36.0 以上。
```bash
cd /opt/vaultwarden && bash upgrade.sh
```
如果暂时无法升级,可在 Nginx 的 443 server 块内加一条重写作为临时过渡:
```nginx
rewrite ^/identity/accounts/prelogin/password/?$ /identity/accounts/prelogin last;
```
### 502 Bad Gateway
```bash
+84 -3
View File
@@ -24,11 +24,72 @@ source "$BASE_DIR/setup.sh"
# Vaultwarden 专用函数
# =============================================================
GITHUB_REPO="dani-garcia/vaultwarden"
# 生成随机密码
generate_password() {
openssl rand -base64 32 | tr -d '/+=' | head -c 32
}
# 查询 GitHub 上的最新 release 版本号(不含前缀 v),失败返回非零
resolve_latest_version() {
local ver
ver=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
[ -n "$ver" ] || return 1
echo "$ver"
}
# 把镜像固定到具体版本号。
# 必须同时更新 .env 和已导出的环境变量:init_env 里 `set -a; source .env` 已把旧值
# 导出到本进程,而 docker compose 对环境变量的优先级高于 .env 文件,只改文件不生效。
pin_image_version() {
local ref="$1"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$ref" >> .env
fi
export VAULTWARDEN_IMAGE="$ref"
}
# 确定本次部署使用的镜像版本。
# 不使用 latest 标签的原因:它只在 docker compose pull 时才重新解析,容器不重建就会
# 一直跑旧版本,且从配置上看不出实际运行的是哪一版。
setup_image_version() {
local current="${VAULTWARDEN_IMAGE:-}"
# 已固定到具体版本 → 尊重现有配置,升级交给 upgrade.sh
if [ -n "$current" ] && [[ "$current" != *":latest" ]]; then
log " 镜像: ${current}"
return 0
fi
# 已有实例却仍在用 latest:此时固定版本等同于一次升级,
# 而升级必须走 upgrade.sh(带冷备份、完整性校验、自动回滚),这里不擅自处理。
if docker compose ps -a -q vaultwarden 2>/dev/null | grep -q .; then
warn "检测到已存在的实例,且 .env 仍在使用 latest 标签"
warn "latest 不会自动更新,也看不出实际运行版本,建议固定到具体版本号:"
warn " bash upgrade.sh # 安全升级到最新版,并自动写回版本号"
warn "本次部署不改动镜像配置"
return 0
fi
log "正在查询 Vaultwarden 最新版本..."
local ver
if ! ver="$(resolve_latest_version)"; then
warn "无法从 GitHub 获取最新版本号(网络问题?),本次回退使用 latest 标签"
warn "部署完成后建议手动固定: VAULTWARDEN_IMAGE=vaultwarden/server:<版本号>"
[ -n "$current" ] || pin_image_version "vaultwarden/server:latest"
return 0
fi
pin_image_version "vaultwarden/server:${ver}"
log " 镜像: vaultwarden/server:${ver}(当前最新 release,已写入 .env"
}
init_env() {
step "初始化 Vaultwarden 配置"
@@ -83,6 +144,8 @@ init_env() {
log " 域名: ${VAULTWARDEN_DOMAIN}"
log " 邮箱: ${CERTBOT_EMAIL}"
log " 注册: ${SIGNUPS_ALLOWED:-true}"
setup_image_version
}
create_dirs() {
@@ -95,21 +158,39 @@ create_dirs() {
log "备份目录: $backup_dir"
}
# 确认容器真的跑在配置指定的镜像上,避免「配置改了但容器没重建」这类静默失败
verify_running_version() {
local cid actual expect="${VAULTWARDEN_IMAGE:-}"
cid=$(docker compose ps -a -q vaultwarden 2>/dev/null | head -1)
[ -n "$cid" ] || return 0
actual=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
if [ -n "$expect" ] && [ "$actual" != "$expect" ]; then
warn "容器实际镜像 (${actual}) 与配置 (${expect}) 不一致"
warn "请检查: docker compose up -d --force-recreate"
return 0
fi
log " 运行镜像: ${actual}"
}
start_services() {
step "启动 Vaultwarden 服务"
log "正在拉取镜像..."
docker compose pull
# --force-recreate:容器处于 stopped 状态时,up -d 只会把原容器重新 start,
# 不会因镜像或环境变量变更而重建,导致「配置改了但没生效」。
log "正在启动容器..."
docker compose up -d
docker compose up -d --force-recreate
local port="${VAULTWARDEN_PORT:-8080}"
log "等待 Vaultwarden 就绪..."
local max_wait=30
for i in $(seq 1 "$max_wait"); do
for _ in $(seq 1 "$max_wait"); do
if curl -sf "http://127.0.0.1:${port}/alive" &> /dev/null; then
log "Vaultwarden 启动成功!"
verify_running_version
return
fi
sleep 2
@@ -144,7 +225,7 @@ show_info() {
if [[ "${SIGNUPS_ALLOWED:-true}" == "true" ]]; then
echo -e "${GREEN}${NC} ${YELLOW}⚠ 注册功能已开启,注册完账号后建议关闭:${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 修改 .env 中 SIGNUPS_ALLOWED=false${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose restart${NC}"
echo -e "${GREEN}${NC} ${YELLOW} 然后 docker compose up -d(不是 restart${NC}"
fi
echo -e "${GREEN}${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env bash
# -E 让 ERR trap 能在函数内部生效,保证不可逆区间的异常一定触发回滚
set -Eeuo pipefail
# ============================================
# Vaultwarden 安全升级脚本
#
# 设计原则:
# 1. 先拉镜像再动服务 —— 网络失败时服务零影响
# 2. 冷备份(停容器 + WAL checkpoint)—— 保证快照一致
# 3. 升级前后比对数据指纹 —— 数据异常自动回滚
# 4. 回滚不删数据 —— 旧数据目录改名保留,不 rm
#
# 用法:
# bash upgrade.sh 升级到 GitHub 最新 release
# bash upgrade.sh --check 只检查,不做任何改动
# bash upgrade.sh --version 1.37.0
# bash upgrade.sh --rollback 回滚到上次升级前的状态
# bash upgrade.sh --yes 跳过交互确认(用于自动化)
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ===== 输出样式 =====
if [ -t 1 ]; then
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
log() { echo -e "${GREEN}[✓]${NC} $*"; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error(){ echo -e "${RED}[✗]${NC} $*" >&2; }
step() { echo ""; echo -e "${BOLD}${CYAN}━━━ $* ━━━${NC}"; }
# ===== 全局状态 =====
GITHUB_REPO="dani-garcia/vaultwarden"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
STATE_FILE="$SCRIPT_DIR/.upgrade-state"
MODE="upgrade" # upgrade | check | rollback
TARGET_VERSION=""
ASSUME_YES=0
SAFETY_STAGE="none" # none → stopped → armed → done,决定异常退出时如何兜底
BACKUP_PATH=""
ROLLBACK_TAG=""
OLD_IMAGE_REF=""
COUNTS_BEFORE=""
# =============================================================
# 参数解析
# =============================================================
usage() {
cat <<'EOF'
Vaultwarden 安全升级脚本
用法: bash upgrade.sh [选项]
选项:
-c, --check 只做预检和版本对比,不做任何改动
-v, --version <ver> 指定目标版本(如 1.37.0),默认取 GitHub 最新 release
-r, --rollback 回滚到上次升级前的状态(读取 .upgrade-state
-y, --yes 跳过交互确认
-h, --help 显示本帮助
示例:
bash upgrade.sh --check # 先看看会发生什么
bash upgrade.sh # 正式升级
bash upgrade.sh --rollback # 升级后发现问题,回滚
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
-c|--check) MODE="check" ;;
-r|--rollback) MODE="rollback" ;;
-y|--yes) ASSUME_YES=1 ;;
-v|--version)
shift
[ $# -gt 0 ] || { error "--version 需要一个参数"; exit 1; }
TARGET_VERSION="${1#v}"
;;
-h|--help) usage; exit 0 ;;
*) error "未知参数: $1"; echo ""; usage; exit 1 ;;
esac
shift
done
}
confirm() {
[ "$ASSUME_YES" -eq 1 ] && return 0
local prompt="$1" answer
echo ""
read -r -p "${prompt} [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
# =============================================================
# 环境加载与预检
# =============================================================
load_env() {
[ -f .env ] || { error ".env 不存在,请先运行 deploy.sh"; exit 1; }
sed -i 's/\r$//' .env
set -a; source .env; set +a
DATA_DIR="${VAULTWARDEN_DATA_DIR:-/var/lib/vaultwarden}"
BACKUP_BASE="${BACKUP_DIR:-/var/backups/vaultwarden}"
PORT="${VAULTWARDEN_PORT:-8080}"
DOMAIN="${VAULTWARDEN_DOMAIN:-}"
CURRENT_IMAGE="${VAULTWARDEN_IMAGE:-vaultwarden/server:latest}"
DB_FILE="$DATA_DIR/db.sqlite3"
}
preflight() {
step "环境预检"
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限运行"; exit 1; }
local missing=0
for cmd in docker curl tar; do
command -v "$cmd" >/dev/null 2>&1 || { error "缺少命令: $cmd"; missing=1; }
done
docker compose version >/dev/null 2>&1 || { error "docker compose 不可用"; missing=1; }
[ "$missing" -eq 0 ] || exit 1
[ -f docker-compose.yml ] || { error "docker-compose.yml 不存在"; exit 1; }
[ -d "$DATA_DIR" ] || { error "数据目录不存在: $DATA_DIR"; exit 1; }
[ -f "$DB_FILE" ] || { error "数据库不存在: $DB_FILE"; exit 1; }
# 磁盘空间:要求可用空间 ≥ 数据目录的 3 倍(备份 + 解压余量),至少 500MB
mkdir -p "$BACKUP_BASE"
local data_kb avail_kb need_kb
data_kb=$(du -sk "$DATA_DIR" | cut -f1)
avail_kb=$(df -Pk "$BACKUP_BASE" | awk 'NR==2{print $4}')
[ -n "$avail_kb" ] || { error "无法检测 $BACKUP_BASE 的可用空间"; exit 1; }
need_kb=$(( data_kb * 3 ))
[ "$need_kb" -lt 512000 ] && need_kb=512000
if [ "$avail_kb" -lt "$need_kb" ]; then
error "磁盘空间不足: 可用 $((avail_kb/1024))MB,需要 $((need_kb/1024))MB"
exit 1
fi
log "root 权限、依赖命令、数据目录、磁盘空间 均正常"
info "数据目录 $DATA_DIR ($((data_kb/1024))MB),可用空间 $((avail_kb/1024))MB"
}
ensure_sqlite3() {
command -v sqlite3 >/dev/null 2>&1 && return 0
warn "sqlite3 未安装,正在安装(用于 WAL checkpoint 和完整性校验)..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sqlite3
elif command -v yum >/dev/null 2>&1; then
yum install -y -q sqlite
else
error "无法自动安装 sqlite3,请手动安装后重试"
exit 1
fi
command -v sqlite3 >/dev/null 2>&1 || { error "sqlite3 安装失败"; exit 1; }
log "sqlite3 已安装"
}
# =============================================================
# 版本解析
# =============================================================
resolve_target_version() {
if [ -n "$TARGET_VERSION" ]; then
info "使用指定版本: $TARGET_VERSION"
return 0
fi
info "查询 GitHub 最新 release..."
TARGET_VERSION=$(curl -sf --max-time 20 \
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//') || true
if [ -z "$TARGET_VERSION" ]; then
error "无法从 GitHub 获取最新版本(网络问题?)"
error "请手动指定: bash upgrade.sh --version 1.37.0"
exit 1
fi
log "GitHub 最新 release: $TARGET_VERSION"
}
# 从 compose 项目推导容器 ID(而非硬编码容器名),停止状态也能拿到
compose_container_id() {
docker compose ps -a -q vaultwarden 2>/dev/null | head -1 || true
}
# 当前容器实际运行的镜像 ID(sha256:...),拿不到则返回空
running_image_id() {
local cid
cid="$(compose_container_id)"
[ -n "$cid" ] || return 0
docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true
}
get_running_githash() {
curl -sf --max-time 10 "http://127.0.0.1:${PORT}/api/config" 2>/dev/null \
| grep -o '"gitHash"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"\([^"]*\)"$/\1/' || true
}
# =============================================================
# 数据指纹
# =============================================================
# 统计关键表行数,用于升级前后比对。表不存在时输出 "-"
snapshot_counts() {
local db="$1" t out=""
for t in users ciphers folders sends attachments organizations collections; do
local n
n=$(sqlite3 "$db" "SELECT COUNT(*) FROM $t;" 2>/dev/null || echo "-")
out+="${t}=${n} "
done
echo "${out% }"
}
print_counts() {
local label="$1" counts="$2" kv
echo " ${label}:"
for kv in $counts; do
printf " %-16s %s\n" "${kv%%=*}" "${kv##*=}"
done
}
# =============================================================
# 备份
# =============================================================
cold_backup() {
step "冷备份(服务已停止,快照一致)"
mkdir -p "$BACKUP_BASE"
info "执行 WAL checkpoint,将未落盘数据合并进主库..."
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE);" >/dev/null
log "WAL 已合并"
info "校验数据库完整性..."
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "数据库完整性检查未通过: $integrity"
error "升级已中止,服务未改动。请先修复数据库。"
docker compose up -d
exit 1
fi
log "数据库完整性: ok"
COUNTS_BEFORE="$(snapshot_counts "$DB_FILE")"
print_counts "升级前数据统计" "$COUNTS_BEFORE"
BACKUP_PATH="${BACKUP_BASE}/pre-upgrade-${TIMESTAMP}.tar.gz"
info "打包整个数据目录 → $BACKUP_PATH"
tar czf "$BACKUP_PATH" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
# 校验备份包。注意:这里先把清单收进变量再用 here-string 匹配,
# 不能写成 `tar tzf ... | grep -q`grep -q 命中即退出会让 tar 收到
# SIGPIPE,在 pipefail 下整条管道被判为失败,导致好备份被误判成坏的。
info "校验备份包..."
local listing
listing="$(tar tzf "$BACKUP_PATH")" || { error "备份包损坏,无法列出内容"; exit 1; }
grep -q 'db\.sqlite3$' <<<"$listing" || { error "备份包中没有数据库文件"; exit 1; }
grep -q 'rsa_key\.pem$' <<<"$listing" || warn "备份包中没有 rsa_key.pem"
# 同时备份 .env(含 ADMIN_TOKEN
cp .env "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
chmod 600 "${BACKUP_BASE}/env-${TIMESTAMP}.bak"
log "备份完成: $BACKUP_PATH ($(du -h "$BACKUP_PATH" | cut -f1))"
}
# =============================================================
# 镜像与容器
# =============================================================
tag_rollback_image() {
step "锚定当前镜像(用于回滚)"
local image_id
image_id="$(running_image_id)"
if [ -z "$image_id" ]; then
image_id=$(docker image inspect "$CURRENT_IMAGE" --format '{{.Id}}' 2>/dev/null || true)
fi
[ -n "$image_id" ] || { error "找不到当前运行的镜像,无法建立回滚点"; exit 1; }
ROLLBACK_TAG="vaultwarden/server:pre-upgrade-${TIMESTAMP}"
docker tag "$image_id" "$ROLLBACK_TAG"
OLD_IMAGE_REF="$CURRENT_IMAGE"
log "当前镜像已标记为 $ROLLBACK_TAG"
info "镜像 ID: ${image_id#sha256:}"
}
pull_target_image() {
step "拉取目标镜像(此步骤不影响运行中的服务)"
local target="vaultwarden/server:${TARGET_VERSION}"
info "docker pull $target ..."
if ! docker pull "$target"; then
error "镜像拉取失败,服务未做任何改动,可安全重试"
exit 1
fi
log "镜像已就绪: $target"
}
switch_image_in_env() {
local new_ref="$1"
cp .env ".env.bak-${TIMESTAMP}"
chmod 600 ".env.bak-${TIMESTAMP}"
if grep -q '^VAULTWARDEN_IMAGE=' .env; then
sed -i "s|^VAULTWARDEN_IMAGE=.*|VAULTWARDEN_IMAGE=${new_ref}|" .env
else
printf '\nVAULTWARDEN_IMAGE=%s\n' "$new_ref" >> .env
fi
# 必须同步更新导出的变量:load_env 里 `set -a; source .env` 已经把旧值导出到
# 本进程环境,而 docker compose 对环境变量的优先级高于 .env 文件 —— 只改文件
# 的话 compose 仍会读到旧镜像,表现为「容器重建了但版本没变」。
export VAULTWARDEN_IMAGE="$new_ref"
info ".env 中 VAULTWARDEN_IMAGE → $new_ref"
}
wait_healthy() {
local max_wait="${1:-60}" i
info "等待服务就绪(最多 ${max_wait}s..."
for i in $(seq 1 "$max_wait"); do
if curl -sf --max-time 3 "http://127.0.0.1:${PORT}/alive" >/dev/null 2>&1; then
log "服务已就绪(${i}s"
return 0
fi
sleep 1
done
return 1
}
# =============================================================
# 验证
# =============================================================
# 确认容器真的跑在目标镜像上,防止「改了配置但容器没重建」这类静默失败
verify_running_image() {
local expect="$1" cid actual_ref actual_id expect_id
cid="$(compose_container_id)"
[ -n "$cid" ] || { error "找不到容器"; return 1; }
actual_ref=$(docker inspect "$cid" --format '{{.Config.Image}}' 2>/dev/null || true)
actual_id=$(docker inspect "$cid" --format '{{.Image}}' 2>/dev/null || true)
expect_id=$(docker image inspect "$expect" --format '{{.Id}}' 2>/dev/null || true)
if [ "$actual_id" != "$expect_id" ]; then
error "容器未运行在目标镜像上"
error " 期望: $expect ($expect_id)"
error " 实际: $actual_ref ($actual_id)"
return 1
fi
log "容器镜像确认: $actual_ref"
return 0
}
# $1: strict(默认) —— 要求新版 prelogin 路由存在(升级后)
# lenient —— 该路由 404 属正常(回滚到 1.36.0 之前的版本后)
verify_endpoints() {
local strict="${1:-strict}"
step "接口验证"
local failed=0
# 1. /alive
if curl -sf --max-time 5 "http://127.0.0.1:${PORT}/alive" >/dev/null; then
log "/alive 正常"
else
error "/alive 无响应"; failed=1
fi
# 2. 新版 prelogin 路由(1.36.0+ 才有,是本次升级的核心目标)
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin/password" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin/password → 200(新版客户端可登录)"
elif [ "$strict" = "lenient" ]; then
info "/identity/accounts/prelogin/password → $code(该版本无此路由,回滚后属预期)"
else
error "/identity/accounts/prelogin/password → $code(期望 200"; failed=1
fi
# 3. 旧版 prelogin 路由(兼容老客户端)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"healthcheck@example.invalid"}' \
"http://127.0.0.1:${PORT}/identity/accounts/prelogin" || echo "000")
if [ "$code" = "200" ]; then
log "/identity/accounts/prelogin → 200(旧客户端兼容)"
else
warn "/identity/accounts/prelogin → $code"
fi
# 4. 经 Nginx 的外部访问(失败只告警,属于代理层问题,不触发回滚)
if [ -n "$DOMAIN" ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
"https://${DOMAIN}/api/config" || echo "000")
if [ "$code" = "200" ]; then
log "https://${DOMAIN}/api/config → 200Nginx 反代正常)"
else
warn "https://${DOMAIN}/api/config → $code(检查 Nginx/证书,不影响本次升级判定)"
fi
fi
return $failed
}
verify_data_integrity() {
step "数据完整性验证"
local integrity
integrity=$(sqlite3 "$DB_FILE" "PRAGMA integrity_check;" 2>&1 | head -1)
if [ "$integrity" != "ok" ]; then
error "升级后数据库完整性检查失败: $integrity"
return 1
fi
log "数据库完整性: ok"
local counts_after
counts_after="$(snapshot_counts "$DB_FILE")"
print_counts "升级后数据统计" "$counts_after"
# 逐表比对:行数只允许持平或增加,减少视为数据丢失
local kv table before after lost=0
for kv in $COUNTS_BEFORE; do
table="${kv%%=*}"; before="${kv##*=}"
after=$(echo "$counts_after" | tr ' ' '\n' | grep "^${table}=" | cut -d= -f2 || true)
# 升级前该表就不存在 / 升级后读不到 → 跳过比对
if [ "$before" = "-" ] || [ -z "$after" ] || [ "$after" = "-" ]; then
continue
fi
if [ "$after" -lt "$before" ]; then
error "$table 行数减少: $before$after"
lost=1
fi
done
if [ "$lost" -eq 1 ]; then
error "检测到数据丢失!"
return 1
fi
log "所有表行数无减少,数据完好"
return 0
}
# =============================================================
# 回滚
# =============================================================
save_state() {
cat > "$STATE_FILE" <<EOF
# Vaultwarden 升级状态记录 —— 由 upgrade.sh 自动生成
UPGRADE_TIMESTAMP=${TIMESTAMP}
BACKUP_PATH=${BACKUP_PATH}
ROLLBACK_TAG=${ROLLBACK_TAG}
OLD_IMAGE_REF=${OLD_IMAGE_REF}
NEW_IMAGE_REF=vaultwarden/server:${TARGET_VERSION}
DATA_DIR=${DATA_DIR}
ENV_BACKUP=${SCRIPT_DIR}/.env.bak-${TIMESTAMP}
EOF
chmod 600 "$STATE_FILE"
}
# 恢复数据目录:旧目录改名保留,绝不 rm
restore_data_dir() {
local backup_tar="$1" ts="$2"
local parent failed_dir
parent="$(dirname "$DATA_DIR")"
failed_dir="${DATA_DIR}.failed-${ts}"
if [ -d "$DATA_DIR" ]; then
mv "$DATA_DIR" "$failed_dir"
warn "升级后的数据目录已保留为: $failed_dir(未删除)"
fi
tar xzf "$backup_tar" -C "$parent"
[ -f "$DB_FILE" ] || { error "恢复失败:$DB_FILE 不存在"; return 1; }
log "数据目录已从备份恢复"
}
# 服务已停、但备份尚未就绪时的紧急恢复:此阶段 .env 未改动,直接拉起原版本即可
emergency_start_old() {
step "紧急恢复"
warn "升级在备份阶段中止,数据未被改动,正在拉起原服务..."
docker compose up -d || true
if wait_healthy 60; then
log "原服务已恢复运行,本次升级未造成任何影响"
else
error "原服务恢复失败!请手动执行: cd $SCRIPT_DIR && docker compose up -d"
error "查看日志: docker compose logs --tail 100"
fi
}
# 统一的退出兜底:确保任何异常路径都不会把服务丢在停机状态
on_exit() {
local code=$?
trap - EXIT ERR
[ "$code" -eq 0 ] && return 0
case "$SAFETY_STAGE" in
stopped) emergency_start_old ;;
armed) do_rollback "脚本异常中止(退出码 $code" ;;
*) : ;; # 尚未动过服务,无需处理
esac
return 0
}
do_rollback() {
trap - ERR EXIT # 先解除,避免回滚过程自身出错导致递归
SAFETY_STAGE="done"
set +e
local reason="$1"
step "自动回滚"
error "触发原因: $reason"
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$TIMESTAMP" || {
error "自动恢复失败!请手动处理:"
error " tar xzf $BACKUP_PATH -C $(dirname "$DATA_DIR")"
exit 1
}
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成,已恢复到升级前状态"
else
error "回滚后服务仍未就绪,请检查: docker compose logs --tail 100"
fi
echo ""
warn "升级失败已回滚。备份保留在: $BACKUP_PATH"
exit 1
}
manual_rollback() {
step "手动回滚"
[ -f "$STATE_FILE" ] || { error "找不到升级记录 $STATE_FILE,无法自动回滚"; exit 1; }
# shellcheck disable=SC1090
source "$STATE_FILE"
DB_FILE="$DATA_DIR/db.sqlite3" # 状态文件里的 DATA_DIR 可能与 .env 不同,重新推导
echo ""
info "将回滚到:"
info " 镜像: $ROLLBACK_TAG"
info " 备份: $BACKUP_PATH"
info " 时间: $UPGRADE_TIMESTAMP"
echo ""
[ -f "$BACKUP_PATH" ] || { error "备份文件不存在: $BACKUP_PATH"; exit 1; }
docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1 \
|| { error "回滚镜像不存在: $ROLLBACK_TAG"; exit 1; }
warn "回滚会丢弃升级后产生的所有数据变更(新增/修改的密码条目)"
confirm "确认回滚?" || { info "已取消"; exit 0; }
docker compose down || true
restore_data_dir "$BACKUP_PATH" "$(date +%Y%m%d_%H%M%S)" || exit 1
switch_image_in_env "$ROLLBACK_TAG"
docker compose up -d --force-recreate
if wait_healthy 60; then
log "回滚完成"
verify_endpoints lenient || true
else
error "服务未就绪,请检查: docker compose logs --tail 100"
exit 1
fi
}
# =============================================================
# 主流程
# =============================================================
show_plan() {
local githash
githash="$(get_running_githash)"
echo ""
echo -e "${BOLD}══════════ 升级计划 ══════════${NC}"
echo " 域名: ${DOMAIN:-未配置}"
echo " 当前镜像: $CURRENT_IMAGE"
echo " 当前 gitHash: ${githash:-未知(服务未响应?)}"
echo " 目标版本: vaultwarden/server:${TARGET_VERSION}"
echo " 数据目录: $DATA_DIR"
echo " 备份目录: $BACKUP_BASE"
echo ""
echo " 执行步骤:"
echo " 1. 锚定当前镜像为回滚点"
echo " 2. 拉取新镜像(失败则零影响退出)"
echo " 3. 停止容器"
echo " 4. WAL checkpoint + 完整性校验 + 冷备份"
echo " 5. 切换镜像并启动"
echo " 6. 验证接口 + 比对数据行数"
echo " 7. 任一验证失败 → 自动回滚"
echo -e "${BOLD}═════════════════════════════${NC}"
}
main() {
parse_args "$@"
echo -e "${CYAN}${BOLD}"
echo " Vaultwarden 安全升级"
echo -e "${NC}"
load_env
if [ "$MODE" = "rollback" ]; then
[ "$(id -u)" -eq 0 ] || { error "需要 root 权限"; exit 1; }
manual_rollback
exit 0
fi
preflight
resolve_target_version
show_plan
# 已是目标版本?
local cur_image_id target_image_id
cur_image_id="$(running_image_id)"
target_image_id=$(docker image inspect "vaultwarden/server:${TARGET_VERSION}" \
--format '{{.Id}}' 2>/dev/null || true)
if [ -n "$cur_image_id" ] && [ "$cur_image_id" = "$target_image_id" ]; then
echo ""
log "当前运行的已经是 ${TARGET_VERSION},无需升级"
if [ "$MODE" = "check" ]; then
exit 0
fi
confirm "仍要强制重新部署一次?" || { info "已取消"; exit 0; }
fi
if [ "$MODE" = "check" ]; then
echo ""
info "--check 模式,未做任何改动"
exit 0
fi
confirm "确认开始升级?" || { info "已取消"; exit 0; }
ensure_sqlite3
# 先拉镜像再打回滚标签:拉取失败时服务未动,也不留下无用的标签
pull_target_image
tag_rollback_image
# ===== 从这里开始服务会中断,全程由 on_exit 兜底 =====
trap on_exit EXIT
trap 'exit 1' ERR
step "停止容器"
SAFETY_STAGE="stopped" # 异常退出 → 直接拉起原版本
docker compose stop
log "容器已停止"
cold_backup
save_state
SAFETY_STAGE="armed" # 备份已就位,异常退出 → 完整回滚
step "启动新版本"
switch_image_in_env "vaultwarden/server:${TARGET_VERSION}"
# 必须 --force-recreate:容器处于 stopped 状态时,docker compose up -d 只会
# 把原容器重新 start,不会因为镜像变更而重建,结果是「升级了但还跑着旧镜像」。
docker compose up -d --force-recreate
if ! wait_healthy 90; then
docker compose logs --tail 40 || true
do_rollback "服务在 90 秒内未就绪"
fi
if ! verify_running_image "vaultwarden/server:${TARGET_VERSION}"; then
do_rollback "容器未运行在目标镜像上"
fi
if ! verify_endpoints; then
do_rollback "接口验证失败"
fi
if ! verify_data_integrity; then
do_rollback "数据完整性验证失败"
fi
# ===== 成功 =====
SAFETY_STAGE="done"
trap - EXIT ERR
step "升级成功"
local new_githash
new_githash="$(get_running_githash)"
echo ""
echo -e "${GREEN}${BOLD}══════════ 完成 ══════════${NC}"
echo " 运行版本: vaultwarden/server:${TARGET_VERSION}"
echo " gitHash: ${new_githash:-未知}"
echo " 备份文件: $BACKUP_PATH"
echo " 回滚镜像: $ROLLBACK_TAG"
echo ""
echo " 如需回滚: bash upgrade.sh --rollback"
echo " 查看日志: docker compose logs --tail 50"
echo -e "${GREEN}${BOLD}══════════════════════════${NC}"
echo ""
warn "请登录 Web 端确认数据无误后,再考虑清理旧镜像:"
warn " docker image rm $ROLLBACK_TAG"
}
main "$@"
+27 -3
View File
@@ -6,11 +6,35 @@
# XRAY_MODE=reality
# 监听端口(默认 443,建议不改)
# NAT / 端口转发的机器请填服务商实际映射给你的端口
# XRAY_PORT=443
# Reality 伪装目标(留空则自动选择延迟最低的)
# REALITY_DEST=www.microsoft.com
# REALITY_SNI=www.microsoft.com
# 分享链接中使用的 IP,留空则自动探测出口 IP
# NAT / 端口转发的机器上出口 IP 与入站 IP 可能不同,此时必须手动指定
# SERVER_IP=1.2.3.4
# Reality 伪装目标(留空则自动从候选中挑选,见 deploy.sh 的 select_reality_dest
# 手动指定时务必确认新域名满足两个条件,否则客户端会刷 REALITY 报错:
# 1) TLS 1.3 且证书链 < 8192 字节(用 xray tls ping <域名> 查「with SNI」那段)
# 2) 该域名会被客户端路由判成「直连」(命中 geosite:cn)
# 已知不可用:dl.google.com(命中 geosite:google)、www.microsoft.com、www.amazon.com
# REALITY_DEST=www.apple.com
# REALITY_SNI=www.apple.com
# ===== 定时重启 =====
# 0 = 关闭(默认);N = 每 N 天重启一次 Xray。
# 崩溃恢复已由 systemd 的 Restart=always 覆盖(秒级响应),而硬重启会切断
# 全部活动连接。仅在确实观察到长期运行后性能退化时才启用。
# XRAY_RESTART_EVERY_DAYS=0
# XRAY_RESTART_TIME=04:00
# XRAY_RESTART_TIMEZONE=Asia/Shanghai
#
# 改这几项无需完整重新部署,用轻量入口即可(不影响 vless 链接):
# bash deploy.sh --restart 7 04:00
# bash deploy.sh --restart 0
#
# 旧字段 XRAY_DAILY_RESTART 已废弃,仅在上面三项都没设时作为兼容回退:
# true → 每 1 天,其它 → 关闭。重跑部署后会自动换写成新字段。
# 备份目录
# BACKUP_DIR=/var/backups/xray
+193 -6
View File
@@ -52,14 +52,20 @@
```
vps-xray/
├── deploy.sh # 一键部署脚本
├── backup.sh # 备份脚本
├── uninstall.sh # 完全卸载脚本
├── backup.sh # 备份脚本
├── upload.sh # 上传脚本(仅上传服务端必要文件)
├── .env.example # 配置模板
├── README.md # 本文件
├── vps-xray-optimized.md # Reality 方案详细文档
── vps-xray-fast.md # Fast TCP 方案详细文档
── vps-xray-fast.md # Fast TCP 方案详细文档
└── client-config/ # 客户端配置(本地使用,不上传服务器)
├── gen-clash.sh # 由节点信息生成 Clash.Meta 配置
└── client-macos.md # macOS 客户端指南(模板,可提交)
```
> `client-config/` 下含真实 UUID / 服务器 IP 的节点配置(`*-info*.md`、`*.yaml`)已在根 `.gitignore` 中排除,不要提交到版本库。
服务器上的文件位置:
```
@@ -73,8 +79,11 @@ vps-xray/
### 第一步:上传文件到 VPS
使用 `upload.sh` 只上传服务端必要文件(`deploy.sh``uninstall.sh``.env.example`),跳过文档和客户端配置:
```bash
scp -r vps-xray/ root@<VPS_IP>:/opt/vps-xray
# 在本地 vps-xray/ 目录下执行
bash upload.sh <VPS_IP>
```
### 第二步:登录 VPS 执行部署
@@ -94,7 +103,15 @@ bash deploy.sh --mode fast
- 连接参数(IP、端口、UUID、密钥等)
- VLESS 分享链接(可直接导入客户端)
> **⚠️ 请妥善保存输出的连接信息!密钥仅显示一次。**凭据同时保存在 `/opt/vps-xray/.env` 中。
凭据保存在 `/opt/vps-xray/.env`(权限 600)。**信息不会丢** —— 随时可以重新打印:
```bash
cd /opt/vps-xray && bash deploy.sh --show
```
`--show` 只读取 `.env` 输出连接信息和分享链接,不安装、不改配置、不重启服务。
> 即使 `.env` 被误删,只要 `/usr/local/etc/xray/config.json` 还在,UUID、shortId、SNI 都能从中读出,公钥可用 `xray x25519 -i <privateKey>` 从私钥反推。
### 第三步:客户端配置
@@ -107,16 +124,186 @@ bash deploy.sh --mode fast
| iOS | Shadowrocket / Streisand |
| Android | v2rayNG |
#### 生成 Clash 配置
把每台 VPS 的 `bash deploy.sh --show` 输出保存成 `client-config/xxx-info.md`,然后:
```bash
cd client-config
bash gen-clash.sh # 交互式勾选要写入的节点
bash gen-clash.sh --all # 全部节点
```
脚本解析文件里的 `vless://` 链接,生成 `vpn-vps-xray-optimized.yaml`(多节点自动组成 url-test 自动选择组,原文件会先备份为 `.bak`)。
> 生成的配置需要 **Clash.Meta / mihomo** 内核(Clash Verge Rev、ClashX Meta、FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
详细的 Clash Meta / Sing-Box 配置参见 [vps-xray-optimized.md](vps-xray-optimized.md)。
## 日常运维
### 查看连接信息 / 分享链接
```bash
cd /opt/vps-xray
bash deploy.sh --show
```
只读操作,不会改动任何服务。
### 更换 Reality 伪装目标
伪装目标在首次部署时按握手延迟自动挑选,之后固定写入 `.env` 不再变动。后续每次部署会检查它是否仍然可达,不可达时告警但**不会自动更换**——因为更换会改变 SNI,导致所有客户端链接静默失效。
确认要换时显式执行:
```bash
bash deploy.sh --redetect
```
> ⚠️ 换完后 SNI 变了,**所有客户端都必须用新的分享链接重新配置**。脚本会在更换时打印醒目提示。
#### 自定义伪装目标时的两个硬条件
`.env` 里手动指定 `REALITY_DEST` / `REALITY_SNI` 前,必须确认新域名同时满足:
**1. 技术条件** — TLS 1.3,且证书链总长 < 8192 字节。上限是 `xtls/reality` 里的硬编码值,超出会直接握手失败(见 [XTLS/Xray-core#6356](https://github.com/XTLS/Xray-core/issues/6356)`www.microsoft.com` 的 8273 字节即触发)。在服务器上自查:
```bash
xray tls ping www.apple.com # 只看「Pinging with SNI」那一段
```
**2. 客户端路由条件** — 该域名必须会被客户端判成「直连」。
这条不直观。TUN 模式下客户端偶尔会把「连接本代理服务器」这件事本身也抓进代理(v2rayN 的 sing-box + xray 双核心靠进程嗅探防回环,Windows 上存在竞态)。这种回环连接的去向完全由 SNI 决定:
| SNI 被判为 | 结果 |
|---|---|
| 直连 | 连接真的到达本服务器,REALITY 握手成功,**用户无感** |
| 走代理 | 进隧道 → 服务器替客户端连了真正的伪装站 → 客户端收到真证书 → 刷屏 `REALITY: received real certificate` |
实测(v2rayN 默认规则模板,`geosite:google → proxy` 排在 `geosite:cn → direct` 之前):
| 域名 | 命中规则 | 可用性 |
|---|---|---|
| `www.apple.com``*.apple.com` | `geosite:cn` | ✅ 安全 |
| `dl.google.com` | `geosite:google` | ❌ 不安全 |
| `www.microsoft.com` | 都不命中 → `final:proxy` | ❌ 不安全 |
| `www.amazon.com` | 都不命中 → `final:proxy` | ❌ 不安全 |
脚本的候选列表因此只保留 Apple 系域名。
### 定时重启
默认**关闭**。原因是:
- 崩溃恢复已由 systemd 的 `Restart=always` + `RestartSec=3` 覆盖(秒级响应),不必等到凌晨
- Xray 无已知需要定期重启的缺陷(内存、句柄上限均已配置)
- 而硬重启会切断全部活动连接——跨夜下载、备份、CI 都会中断
即"为假想问题付出真实代价"。仅在确实观察到长期运行后性能退化时才启用。
需要时用轻量入口调整,**不会重装 Xray、不会重写配置、不会重启服务,现有 vless 链接完全不受影响**:
```bash
bash deploy.sh --restart 7 04:00 # 每 7 天 04:00 重启
bash deploy.sh --restart 3 # 每 3 天,时间沿用 .env
bash deploy.sh --restart 0 # 关闭
bash deploy.sh --restart # 按 .env 现有值重建 timer
```
对应 `.env` 三个字段:
| 字段 | 含义 | 默认 |
|---|---|---|
| `XRAY_RESTART_EVERY_DAYS` | 0 = 关闭;N = 每 N 天 | `0` |
| `XRAY_RESTART_TIME` | 24 小时制 `HH:MM` | `04:00` |
| `XRAY_RESTART_TIMEZONE` | 时区 | `Asia/Shanghai` |
#### 查看当前配置与重启记录
**一条命令看全** —— 不带参数的 `--restart` 只按 `.env` 现值重建 timer,不改配置、不重启服务:
```bash
bash deploy.sh --restart
```
输出里包含当前间隔与时间、**上次实际重启**、**下次实际重启**。
分项查看:
```bash
# 1. 配置值
grep '^XRAY_RESTART_' /opt/vps-xray/.env
# 2. timer 是否启用、下次唤醒时间
systemctl list-timers xray-restart.timer
# 3. 确认时区真的生效(最容易出错的一项)
systemctl show xray-restart.timer -p TimersCalendar
# 4. 上次实际重启的时间戳
cat /var/lib/xray/last-restart # Unix 时间戳
date -d "@$(cat /var/lib/xray/last-restart)" # 可读格式
# 5. 守卫每次唤醒后的判断记录(跳过还是执行)
journalctl -u xray-restart.service --no-pager -n 50
# 6. xray 进程最近一次启动的时刻
systemctl show xray -p ActiveEnterTimestamp --value
```
第 3 条的输出应形如:
```
{ OnCalendar=*-*-* 04:00:00 Asia/Shanghai ; next_elapse=Fri 2026-08-07 20:00:00 UTC }
```
**里面必须看得到时区。** 若只有 `OnCalendar=*-*-* 04:00:00` 而没有时区,说明时区没生效,systemd 会按服务器的系统时区解析——多数 VPS 是 UTC,那样设的「04:00 北京时间」实际会在**中午 12 点**触发,偏移 8 小时且没有任何提示。
> 早期实现把时区写成 `[Timer]` 段的 `TimeZone=`,而 `systemd.timer` 根本没有这个 key——写了只会在 journal 里留一行 `Unknown key name 'TimeZone' in section 'Timer', ignoring.` 然后按本地时区解析。排查时可以顺手确认一下:
>
> ```bash
> journalctl -b | grep -i "unknown key name"
> systemd-analyze verify /etc/systemd/system/xray-restart.timer
> ```
第 5 条的日志长这样:
```
xray-restart-guard[1234]: 距上次重启不足 7 天(已过 3 天),跳过
xray-restart-guard[5678]: 距上次重启已满 7 天,执行重启
```
出现下面这行说明状态文件写不进去(磁盘满或只读挂载),守卫会主动跳过本轮——宁可不重启,也不让保护失效后退化成每天硬重启:
```
xray-restart-guard[...]: 无法写入 /var/lib/xray/last-restart,跳过本轮重启以免退化成每日重启
```
> ⚠️ `list-timers` 的 `NEXT` 是 **timer 下次「唤醒」的时间,不是下次重启的时间**。timer 每天唤醒一次,NEXT 因此恒为次日的 `XRAY_RESTART_TIME`;是否真的重启由守卫按间隔判断。设了「每 7 天」且 5 天前刚重启过时,NEXT 显示明天凌晨,实际还要再等 2 天。想看真实的下次重启时间,用上面那条不带参数的 `bash deploy.sh --restart`。
#### 为什么不是纯 systemd timer
`OnCalendar` 表达不了任意 N 天:`*-*-1/7 04:00:00` 是「每月 1、8、15、22、29 号」,**跨月会重置**——29 号到下月 1 号只隔 2~3 天。
所以实现是 timer 每天到点唤醒,由 `/usr/local/bin/xray-restart-guard``/var/lib/xray/last-restart`,满 N 天才真正重启。间隔因此是「距上次实际重启」的真实天数,`N=3``N=7` 同样准确。
具体怎么查看配置与执行记录,见上面的「查看当前配置与重启记录」。
#### 已知取舍
- **间隔锚点是「上次由守卫执行的重启」,不是任何一次 xray 重启。** 完整部署(`bash deploy.sh`)里的 `systemctl restart xray` 不会更新 `/var/lib/xray/last-restart`——设了每 7 天、距上次守卫重启已 6.9 天时跑一次完整部署,次日守卫仍会判定满 7 天而再重启一次。介意的话可在部署后手动执行 `date +%s > /var/lib/xray/last-restart` 把锚点对齐。
- **重启失败时时间戳仍已更新**,本轮被跳过,下一轮照常——这是为了避免陷入每天硬重启的循环。但若时间戳**写不进去**(磁盘满、只读挂载),守卫会跳过本轮重启并在日志里报错,宁可不重启也不退化成每日重启。
> 旧字段 `XRAY_DAILY_RESTART` 已废弃。三个新字段都未设置时它仍作为兼容回退(`true` → 每 1 天,其它 → 关闭),重跑部署后 `.env` 会自动换写成新字段。
### 查看状态 / 日志
```bash
systemctl status xray
journalctl -u xray -f
journalctl -u xray --tail 100
journalctl -u xray -n 100
```
### 备份
@@ -235,7 +422,7 @@ systemctl status xray
ss -tlnp | grep 443
# 查看错误日志
journalctl -u xray --tail 50
journalctl -u xray -n 50
```
### 速度慢
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================
# Xray 备份脚本
# 备份 Xray 配置 / 部署配置 / 网络调优参数
# 输出目录默认 /var/backups/xray,自动清理 30 天前的旧备份
# ============================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [ "$(id -u)" -ne 0 ]; then
error "请使用 root 用户运行: sudo bash backup.sh"
exit 1
fi
if [ -f .env ]; then
sed -i 's/\r$//' .env
set -a; source .env; set +a
fi
BACKUP_DIR="${BACKUP_DIR:-/var/backups/xray}"
KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}"
STAMP="$(date '+%Y%m%d%H%M%S')"
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
backed_up=0
# ===== 1. Xray 配置 =====
# 打包 /usr/local/etc/xray 整个目录,恢复时 tar xzf ... -C /usr/local/etc/
if [ -d /usr/local/etc/xray ]; then
tar czf "${BACKUP_DIR}/xray_config_${STAMP}.tar.gz" -C /usr/local/etc xray
log "Xray 配置已备份: ${BACKUP_DIR}/xray_config_${STAMP}.tar.gz"
backed_up=$((backed_up + 1))
else
warn "/usr/local/etc/xray 不存在,跳过"
fi
# ===== 2. 部署配置(.env + 脚本)=====
# .env 含私钥,打包后权限收紧到 600
deploy_files=()
for f in .env deploy.sh uninstall.sh backup.sh upload.sh .env.example; do
[ -f "$f" ] && deploy_files+=("$f")
done
if [ "${#deploy_files[@]}" -gt 0 ]; then
tar czf "${BACKUP_DIR}/deploy_${STAMP}.tar.gz" -C "$SCRIPT_DIR" "${deploy_files[@]}"
chmod 600 "${BACKUP_DIR}/deploy_${STAMP}.tar.gz"
log "部署配置已备份: ${BACKUP_DIR}/deploy_${STAMP}.tar.gz(权限 600,含私钥)"
backed_up=$((backed_up + 1))
else
warn "未找到可备份的部署文件,跳过"
fi
# ===== 3. 网络调优参数 =====
if [ -f /etc/sysctl.d/99-xray-turbo.conf ]; then
cp /etc/sysctl.d/99-xray-turbo.conf "${BACKUP_DIR}/sysctl_${STAMP}.conf"
log "网络调优参数已备份: ${BACKUP_DIR}/sysctl_${STAMP}.conf"
backed_up=$((backed_up + 1))
else
warn "/etc/sysctl.d/99-xray-turbo.conf 不存在,跳过"
fi
if [ "$backed_up" -eq 0 ]; then
error "没有任何内容被备份,请确认 Xray 是否已部署"
exit 1
fi
# ===== 4. 清理过期备份 =====
old_count=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
-mtime "+${KEEP_DAYS}" | wc -l)
if [ "$old_count" -gt 0 ]; then
find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'xray_config_*.tar.gz' -o -name 'deploy_*.tar.gz' -o -name 'sysctl_*.conf' \) \
-mtime "+${KEEP_DAYS}" -delete
log "已清理 ${old_count} 个超过 ${KEEP_DAYS} 天的旧备份"
fi
echo ""
log "备份完成,目录: ${BACKUP_DIR}"
ls -lh "$BACKUP_DIR" | tail -n +2 | tail -10
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================
# Clash.Meta 配置生成器
#
# 从 deploy.sh 输出的节点信息文件(*info*.md,内含 vless:// 链接)
# 生成 Clash.Meta / mihomo 可用的 YAML 配置。
#
# 用法:
# bash gen-clash.sh # 交互式选择节点文件
# bash gen-clash.sh a.md b.md # 直接指定
# bash gen-clash.sh -o my.yaml a.md # 指定输出文件
# bash gen-clash.sh --all # 使用全部 *info*.md
#
# 本脚本仅在本地运行,不会上传到 VPS。
# ============================================
# 不切换工作目录:候选文件在脚本所在目录里找,
# 但命令行显式传入的路径仍相对调用者的当前目录解析
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
OUTPUT="${SCRIPT_DIR}/vpn-vps-xray-optimized.yaml"
USE_ALL=0
FILES=()
usage() {
sed -n '4,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) OUTPUT="${2:?-o 需要一个文件名}"; shift 2 ;;
--all) USE_ALL=1; shift ;;
-h|--help) usage ;;
-*) error "未知参数: $1"; usage ;;
*) FILES+=("$1"); shift ;;
esac
done
# ===== 收集候选文件 =====
mapfile -t CANDIDATES < <(ls -1 "${SCRIPT_DIR}"/*info*.md 2>/dev/null || true)
if [ "${#FILES[@]}" -eq 0 ]; then
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
error "当前目录下找不到任何 *info*.md 节点信息文件"
error "请先在 VPS 上执行 bash deploy.sh --show,把输出保存为 xxx-info.md 放到这里"
exit 1
fi
if [ "$USE_ALL" -eq 1 ]; then
FILES=("${CANDIDATES[@]}")
else
echo ""
echo -e "${CYAN}可用的节点信息文件:${NC}"
for i in "${!CANDIDATES[@]}"; do
f="${CANDIDATES[$i]}"
# 顺带把链接里的备注名显示出来,方便辨认
tag=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null | sed 's/.*#//' || true)
printf " %2d) %-45s %s\n" "$((i + 1))" "${f#"${SCRIPT_DIR}"/}" "${tag:+[${tag}]}"
done
echo ""
read -r -p "选择要写入配置的节点(空格分隔序号,回车=全选): " picks
if [ -z "${picks// /}" ]; then
FILES=("${CANDIDATES[@]}")
else
for n in $picks; do
if ! [[ "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ] || [ "$n" -gt "${#CANDIDATES[@]}" ]; then
error "无效序号: $n"
exit 1
fi
FILES+=("${CANDIDATES[$((n - 1))]}")
done
fi
fi
fi
# ===== 工具函数 =====
urldecode() {
local s="${1//+/ }"
printf '%b' "${s//%/\\x}"
}
# 从 query string 中取出指定参数
qs_get() {
local query="$1" key="$2" kv
local IFS='&'
for kv in $query; do
if [ "${kv%%=*}" = "$key" ]; then
urldecode "${kv#*=}"
return
fi
done
}
# ===== 解析所有节点 =====
NAMES=(); SERVERS=(); PORTS=(); UUIDS=(); SNIS=(); PBKS=(); SIDS=(); FPS=(); FLOWS=(); SECS=()
declare -A name_seen=()
for f in "${FILES[@]}"; do
[ -f "$f" ] || { error "文件不存在: $f"; exit 1; }
link=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null || true)
if [ -z "$link" ]; then
warn "跳过 ${f#"${SCRIPT_DIR}"/}:未找到 vless:// 链接"
continue
fi
rest="${link#vless://}"
# 备注名(# 之后)
if [[ "$rest" == *"#"* ]]; then
name=$(urldecode "${rest##*#}")
rest="${rest%%#*}"
else
name=""
fi
uuid="${rest%%@*}"
hpq="${rest#*@}"
hostport="${hpq%%\?*}"
query=""
[[ "$hpq" == *"?"* ]] && query="${hpq#*\?}"
# 兼容 IPv6 字面量 [::1]:443
if [[ "$hostport" == \[* ]]; then
server="${hostport%%]*}]"
port="${hostport##*]:}"
else
server="${hostport%%:*}"
port="${hostport##*:}"
fi
sec=$(qs_get "$query" security); sec="${sec:-none}"
sni=$(qs_get "$query" sni)
pbk=$(qs_get "$query" pbk)
sid=$(qs_get "$query" sid)
fp=$(qs_get "$query" fp); fp="${fp:-chrome}"
flow=$(qs_get "$query" flow)
net=$(qs_get "$query" type); net="${net:-tcp}"
if [ -z "$uuid" ] || [ -z "$server" ] || [ -z "$port" ]; then
error "${f#"${SCRIPT_DIR}"/} 的链接缺少 uuid/server/port,无法解析"
exit 1
fi
if [ "$sec" = "reality" ] && { [ -z "$pbk" ] || [ -z "$sni" ]; }; then
error "${f#"${SCRIPT_DIR}"/} 是 reality 节点但缺少 pbk 或 sni"
exit 1
fi
if [ "$net" != "tcp" ]; then
warn "${f#"${SCRIPT_DIR}"/}: transport=${net},本生成器目前只支持 tcp,已跳过"
continue
fi
[ -z "$name" ] && name="${server}"
# 名称去重,Clash 要求 proxy name 唯一
base="$name"; n=2
while [ -n "${name_seen[$name]:-}" ]; do
name="${base}-${n}"; n=$((n + 1))
done
name_seen["$name"]=1
NAMES+=("$name"); SERVERS+=("$server"); PORTS+=("$port"); UUIDS+=("$uuid")
SNIS+=("$sni"); PBKS+=("$pbk"); SIDS+=("$sid")
FPS+=("$fp"); FLOWS+=("$flow"); SECS+=("$sec")
log "解析成功: ${name} (${server}:${port}, ${sec})"
done
if [ "${#NAMES[@]}" -eq 0 ]; then
error "没有解析出任何可用节点"
exit 1
fi
# ===== 备份既有输出 =====
if [ -f "$OUTPUT" ]; then
cp "$OUTPUT" "${OUTPUT}.bak"
log "已备份原配置: ${OUTPUT}.bak"
fi
# ===== 生成 YAML =====
{
cat <<'HEADER'
# ============================================================
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
#
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
#
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
# ============================================================
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
skip-domain:
- "+.push.apple.com"
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
# 网络连通性检测等会出问题
fake-ip-filter:
- "*.lan"
- "*.local"
- "*.localdomain"
- "*.home.arpa"
- "+.msftconnecttest.com"
- "+.msftncsi.com"
- "time.*.com"
- "ntp.*.com"
- "+.pool.ntp.org"
- "localhost.ptlogin2.qq.com"
# 用于解析下面 fallback 里 DoH 服务器自身的域名
default-nameserver:
- 223.5.5.5
- 119.29.29.29
nameserver:
- 223.5.5.5
- 119.29.29.29
# fallback 的意义是绕过投毒,必须用加密 DNS——
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
fallback:
- https://1.1.1.1/dns-query
- https://dns.google/dns-query
fallback-filter:
geoip: true
geoip-code: CN
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
proxies:
HEADER
for i in "${!NAMES[@]}"; do
echo " - name: \"${NAMES[$i]}\""
echo " type: vless"
echo " server: ${SERVERS[$i]}"
echo " port: ${PORTS[$i]}"
echo " uuid: ${UUIDS[$i]}"
echo " network: tcp"
echo " udp: true"
if [ "${SECS[$i]}" = "reality" ]; then
echo " tls: true"
[ -n "${FLOWS[$i]}" ] && echo " flow: ${FLOWS[$i]}"
echo " servername: ${SNIS[$i]}"
echo " client-fingerprint: ${FPS[$i]}"
echo " reality-opts:"
echo " public-key: ${PBKS[$i]}"
[ -n "${SIDS[$i]}" ] && echo " short-id: ${SIDS[$i]}"
else
echo " tls: false"
fi
echo ""
done
cat <<'GROUPS_HEAD'
proxy-groups:
- name: "Proxy"
type: select
proxies:
- Auto-Select
GROUPS_HEAD
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
echo " - DIRECT"
echo ""
cat <<'AUTO_HEAD'
- name: "Auto-Select"
type: url-test
url: "http://www.gstatic.com/generate_204"
interval: 300
tolerance: 50
proxies:
AUTO_HEAD
for n in "${NAMES[@]}"; do echo " - \"${n}\""; done
echo ""
echo "rules:"
echo " # 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」"
echo " # 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。"
declare -A srv_seen=()
for s in "${SERVERS[@]}"; do
[ -n "${srv_seen[$s]:-}" ] && continue
srv_seen["$s"]=1
if [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo " - IP-CIDR,${s}/32,DIRECT,no-resolve"
elif [[ "$s" == \[*\] ]]; then
echo " - IP-CIDR6,${s//[\[\]]/}/128,DIRECT,no-resolve"
else
echo " - DOMAIN,${s},DIRECT"
fi
done
cat <<'RULES'
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT
# 兜底。MATCH 是终结规则,其后任何规则都不可达
- MATCH,Proxy
RULES
} > "$OUTPUT"
echo ""
log "已生成: ${OUTPUT}${#NAMES[@]} 个节点)"
for n in "${NAMES[@]}"; do echo " - ${n}"; done
echo ""
warn "该文件含真实凭据,已被根 .gitignore 排除,请勿提交或外传"
@@ -0,0 +1,31 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅
==========================================================
IP : 64.81.25.247
端口 : 443
协议 : VLESS
UUID : 7c7de15f-d408-4534-b367-f6253e850178
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.apple.com
Fingerprint : chrome
PublicKey : AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM
ShortId : fa4235504a09feeb
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://7c7de15f-d408-4534-b367-f6253e850178@64.81.25.247:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=AvX70NC1Tqd1sFBvASY-2oitzS2OpCsZu-H1ucVDUBM&sid=fa4235504a09feeb&type=tcp#vps-reality-lisa-1
==========================================================
⚠️ 请妥善保存以上信息!
配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env
再次查看: bash deploy.sh --show
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
@@ -0,0 +1,131 @@
# ============================================================
# 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖)
#
# ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、
# FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。
#
# ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。
# ============================================================
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
# fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
skip-domain:
- "+.push.apple.com"
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
# 这些域名必须拿到真实 IP,否则局域网发现、时间同步、
# 网络连通性检测等会出问题
fake-ip-filter:
- "*.lan"
- "*.local"
- "*.localdomain"
- "*.home.arpa"
- "+.msftconnecttest.com"
- "+.msftncsi.com"
- "time.*.com"
- "ntp.*.com"
- "+.pool.ntp.org"
- "localhost.ptlogin2.qq.com"
# 用于解析下面 fallback 里 DoH 服务器自身的域名
default-nameserver:
- 223.5.5.5
- 119.29.29.29
nameserver:
- 223.5.5.5
- 119.29.29.29
# fallback 的意义是绕过投毒,必须用加密 DNS——
# 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback
fallback:
- https://1.1.1.1/dns-query
- https://dns.google/dns-query
fallback-filter:
geoip: true
geoip-code: CN
ipcidr:
- 240.0.0.0/4
- 0.0.0.0/32
proxies:
- name: "vps-reality-1"
type: vless
server: 45.32.69.133
port: 443
uuid: 5b05151b-a681-46db-a223-95dd45b13589
network: tcp
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
short-id: c6bdf22bcb510f78
- name: "vps-reality-2"
type: vless
server: 45.32.71.199
port: 443
uuid: cbf3836d-517d-4dfb-86d4-a636bca27713
network: tcp
udp: true
tls: true
flow: xtls-rprx-vision
servername: www.apple.com
client-fingerprint: chrome
reality-opts:
public-key: UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
short-id: ab115ca2444053d1
proxy-groups:
- name: "Proxy"
type: select
proxies:
- Auto-Select
- "vps-reality-1"
- "vps-reality-2"
- DIRECT
- name: "Auto-Select"
type: url-test
url: "http://www.gstatic.com/generate_204"
interval: 300
tolerance: 50
proxies:
- "vps-reality-1"
- "vps-reality-2"
rules:
# 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」
# 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。
- IP-CIDR,45.32.69.133/32,DIRECT,no-resolve
- IP-CIDR,45.32.71.199/32,DIRECT,no-resolve
# 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理)
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
# 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中,
# 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析)
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT
# 兜底。MATCH 是终结规则,其后任何规则都不可达
- MATCH,Proxy
@@ -0,0 +1,30 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅
==========================================================
IP : 45.32.71.199
端口 : 443
协议 : VLESS
UUID : cbf3836d-517d-4dfb-86d4-a636bca27713
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.apple.com
Fingerprint : chrome
PublicKey : UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc
ShortId : ab115ca2444053d1
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://cbf3836d-517d-4dfb-86d4-a636bca27713@45.32.71.199:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=UXWjZU9YEIhZmvLJYgnvd4eKiddR4ewRS2xCnmFwfWc&sid=ab115ca2444053d1&type=tcp#vps-reality-2
==========================================================
⚠️ 请妥善保存以上信息!
配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
@@ -0,0 +1,31 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅
==========================================================
IP : 140.82.23.27
端口 : 443
协议 : VLESS
UUID : 3cac4c5e-c9ae-488f-9cde-0c65b2d23409
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.apple.com
Fingerprint : chrome
PublicKey : 5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8
ShortId : 4c5999dd1b15ff3c
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://3cac4c5e-c9ae-488f-9cde-0c65b2d23409@140.82.23.27:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=5hukUZGoGAs4Fpj2Bc7_1nGQykdaYN1sznG-KsxYUD8&sid=4c5999dd1b15ff3c&type=tcp#vps-reality-3
==========================================================
⚠️ 请妥善保存以上信息!
配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env
再次查看: bash deploy.sh --show
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
@@ -0,0 +1,30 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅
==========================================================
IP : 45.32.69.133
端口 : 443
协议 : VLESS
UUID : 5b05151b-a681-46db-a223-95dd45b13589
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.apple.com
Fingerprint : chrome
PublicKey : IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk
ShortId : c6bdf22bcb510f78
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://5b05151b-a681-46db-a223-95dd45b13589@45.32.69.133:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.apple.com&fp=chrome&pbk=IgxAG9DOh7IFLkGi7Zrn3L00lYDYW9blDWRyzmxU6Bk&sid=c6bdf22bcb510f78&type=tcp#vps-reality-1
==========================================================
⚠️ 请妥善保存以上信息!
配置文件: /usr/local/etc/xray/config.json
凭据备份: /opt/vps-xray/.env
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
+811 -76
View File
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env bash
# deploy.sh 中重启计划相关逻辑的单元测试
#
# 用法: bash vps-xray/tests/test-restart-schedule.sh
#
# 不需要 root,不接触真实 systemd:所有写入路径都被重定向到临时目录,
# systemctl 用 PATH 打桩拦截。
#
# 加载方式说明:deploy.sh 结尾会直接调用 main,且顶层有 root 检查与参数解析,
# 不能整份 source。这里只抽取「多行函数定义」(形如 `name() {` 开头、`}` 结尾)。
# 单行函数(log/warn/error/step)抽不到,正好由下面的桩覆盖。
set -u
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY="${TEST_DIR}/../deploy.sh"
if [ ! -f "$DEPLOY" ]; then
echo "找不到 $DEPLOY" >&2
exit 1
fi
eval "$(sed -n '/^[a-z_][a-z_]*() {$/,/^}$/p' "$DEPLOY")"
# 日志桩:error 保留输出便于断言,其余静音
log() { :; }
warn() { :; }
step() { :; }
error() { echo "ERROR: $*" >&2; }
PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; echo " 期望: $2"; echo " 实际: $3"; }
# is <描述> <实际值> <期望值>
is() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$3" "$2"; fi; }
echo "== normalize_restart_time =="
is "04:00 原样通过" "$(normalize_restart_time 04:00)" "04:00"
is "4:00 补零" "$(normalize_restart_time 4:00)" "04:00"
is "08:30 不被当八进制" "$(normalize_restart_time 08:30)" "08:30"
is "23:59 边界" "$(normalize_restart_time 23:59)" "23:59"
is "00:00 边界" "$(normalize_restart_time 00:00)" "00:00"
normalize_restart_time "24:00" >/dev/null 2>&1
is "24:00 被拒" "$?" "1"
normalize_restart_time "04:60" >/dev/null 2>&1
is "04:60 被拒" "$?" "1"
normalize_restart_time "abc" >/dev/null 2>&1
is "abc 被拒" "$?" "1"
normalize_restart_time "" >/dev/null 2>&1
is "空串被拒" "$?" "1"
echo "== resolve_restart_config =="
reset_env() {
unset XRAY_RESTART_EVERY_DAYS XRAY_RESTART_TIME XRAY_RESTART_TIMEZONE XRAY_DAILY_RESTART
RESTART_EVERY_DAYS=""; RESTART_TIME=""; RESTART_TZ=""
}
reset_env
resolve_restart_config
is "全空时天数默认 0" "$RESTART_EVERY_DAYS" "0"
is "全空时时间默认" "$RESTART_TIME" "04:00"
is "全空时时区默认" "$RESTART_TZ" "Asia/Shanghai"
reset_env; XRAY_RESTART_EVERY_DAYS=7; XRAY_RESTART_TIME=5:30
resolve_restart_config
is "读取新字段天数" "$RESTART_EVERY_DAYS" "7"
is "读取新字段时间并补零" "$RESTART_TIME" "05:30"
reset_env; XRAY_DAILY_RESTART=true
resolve_restart_config
is "迁移 true → 1" "$RESTART_EVERY_DAYS" "1"
reset_env; XRAY_DAILY_RESTART=TRUE
resolve_restart_config
is "迁移 TRUE → 1" "$RESTART_EVERY_DAYS" "1"
reset_env; XRAY_DAILY_RESTART=false
resolve_restart_config
is "迁移 false → 0" "$RESTART_EVERY_DAYS" "0"
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=3
resolve_restart_config
is "新字段优先于旧字段" "$RESTART_EVERY_DAYS" "3"
reset_env; XRAY_DAILY_RESTART=true; XRAY_RESTART_EVERY_DAYS=0
resolve_restart_config
is "新字段显式 0 覆盖旧 true" "$RESTART_EVERY_DAYS" "0"
# 前导零必须按十进制归一化:这个值会原样写进 guard 的 DAYS=
# 而 guard 里是算术展开——010 会变成 8 天,08/09 直接报错导致每天重启
reset_env; XRAY_RESTART_EVERY_DAYS=08
resolve_restart_config
is "08 归一化为 8(不当八进制)" "$RESTART_EVERY_DAYS" "8"
reset_env; XRAY_RESTART_EVERY_DAYS=09
resolve_restart_config
is "09 归一化为 9(不当八进制)" "$RESTART_EVERY_DAYS" "9"
reset_env; XRAY_RESTART_EVERY_DAYS=010
resolve_restart_config
is "010 归一化为 10(不当八进制)" "$RESTART_EVERY_DAYS" "10"
reset_env; XRAY_RESTART_EVERY_DAYS=abc
resolve_restart_config 2>/dev/null
is "天数非数字报错" "$?" "1"
reset_env; XRAY_RESTART_EVERY_DAYS=-1
resolve_restart_config 2>/dev/null
is "天数负数报错" "$?" "1"
reset_env; XRAY_RESTART_TIME=25:00
resolve_restart_config 2>/dev/null
is "时间非法报错" "$?" "1"
reset_env; XRAY_RESTART_TIMEZONE=""
resolve_restart_config 2>/dev/null
is "时区空串报错" "$?" "1"
echo "== update_env_field =="
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cat > "${TMP}/.env" << 'ENVEOF'
# 这是用户自己加的注释
XRAY_PORT=443
XRAY_RESTART_EVERY_DAYS=0
# 尾部注释
ENVEOF
update_env_field "${TMP}/.env" XRAY_RESTART_EVERY_DAYS 7
is "替换已有字段" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${TMP}/.env")" "XRAY_RESTART_EVERY_DAYS=7"
is "保留用户注释" "$(grep -c '这是用户自己加的注释' "${TMP}/.env")" "1"
is "保留尾部注释" "$(grep -c '尾部注释' "${TMP}/.env")" "1"
is "不影响其它字段" "$(grep '^XRAY_PORT=' "${TMP}/.env")" "XRAY_PORT=443"
update_env_field "${TMP}/.env" XRAY_RESTART_TIME 05:30
is "追加缺失字段" "$(grep '^XRAY_RESTART_TIME=' "${TMP}/.env")" "XRAY_RESTART_TIME=05:30"
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Asia/Shanghai
is "含斜杠的值不被 sed 分隔符干扰" \
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Asia/Shanghai"
update_env_field "${TMP}/.env" XRAY_RESTART_TIMEZONE Europe/Berlin
is "含斜杠的值可被再次替换" \
"$(grep '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "XRAY_RESTART_TIMEZONE=Europe/Berlin"
is "字段总行数正确" "$(grep -c '^XRAY_RESTART_TIMEZONE=' "${TMP}/.env")" "1"
update_env_field "${TMP}/nonexistent" FOO bar 2>/dev/null
is "文件不存在返回 1" "$?" "1"
printf 'KEY=old' > "${TMP}/.env2"
update_env_field "${TMP}/.env2" NEWKEY val
is "缺尾换行时不黏连" "$(grep -c '^NEWKEY=val$' "${TMP}/.env2")" "1"
is "缺尾换行时原字段完好" "$(grep -c '^KEY=old$' "${TMP}/.env2")" "1"
echo "== render_restart_units =="
SYSTEMD_DIR="${TMP}/systemd"
GUARD_BIN="${TMP}/bin/xray-restart-guard"
RESTART_STATE_FILE="${TMP}/state/last-restart"
mkdir -p "${TMP}/systemd" "${TMP}/bin" "${TMP}/state"
# systemctl 打桩:记录被调用的参数,避免碰真实 systemd
STUB="${TMP}/stub"
mkdir -p "$STUB"
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
STUBEOF
chmod 755 "${STUB}/systemctl"
export SYSTEMCTL_LOG="${TMP}/systemctl.log"
: > "$SYSTEMCTL_LOG"
export PATH="${STUB}:${PATH}"
render_restart_units 7 04:00 Asia/Shanghai
is "timer 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes)" "yes"
is "service 文件存在" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes)" "yes"
is "guard 可执行" "$([ -x "$GUARD_BIN" ] && echo yes)" "yes"
# 时区必须写在日历表达式里:[Timer] 段没有 TimeZone= 这个 key
# 写了会被 systemd 忽略并按系统本地时区(VPS 多为 UTC)解析,静默偏移 8 小时
is "OnCalendar 每天触发且带时区" \
"$(grep -c '^OnCalendar=\*-\*-\* 04:00:00 Asia/Shanghai$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
is "不再使用非法的 TimeZone= 指令" \
"$(grep -c '^TimeZone=' "${SYSTEMD_DIR}/xray-restart.timer")" "0"
is "Persistent 开启" \
"$(grep -c '^Persistent=true$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
is "service 指向 guard" \
"$(grep -c "^ExecStart=${GUARD_BIN}\$" "${SYSTEMD_DIR}/xray-restart.service")" "1"
is "guard 里天数正确" \
"$(grep -c '^DAYS=7$' "$GUARD_BIN")" "1"
is "guard 里状态文件路径正确" \
"$(grep -c "^STATE=\"${RESTART_STATE_FILE}\"\$" "$GUARD_BIN")" "1"
is "guard 用 PATH 查找 systemctl 而非绝对路径" \
"$(grep -c '^exec systemctl restart xray$' "$GUARD_BIN")" "1"
render_restart_units 3 23:05 Europe/Berlin
is "重复渲染覆盖天数" "$(grep -c '^DAYS=3$' "$GUARD_BIN")" "1"
is "重复渲染覆盖时间与时区" \
"$(grep -c '^OnCalendar=\*-\*-\* 23:05:00 Europe/Berlin$' "${SYSTEMD_DIR}/xray-restart.timer")" "1"
remove_restart_units
is "remove 后 timer 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
is "remove 后 service 消失" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
is "remove 后 guard 消失" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
remove_restart_units
is "重复 remove 返回 1" "$?" "1"
echo "== guard 脚本行为 =="
render_restart_units 7 04:00 Asia/Shanghai
rm -f "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
# 无状态文件 → 首次必须执行
out=$("$GUARD_BIN" 2>&1)
is "首次执行重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
is "首次写入时间戳" "$([ -f "$RESTART_STATE_FILE" ] && echo yes)" "yes"
# 刚重启过 → 必须跳过
: > "$SYSTEMCTL_LOG"
out=$("$GUARD_BIN" 2>&1)
is "刚重启过则跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
is "跳过时有提示" "$(echo "$out" | grep -c '跳过')" "1"
# 距上次 6 天 → 仍未满 7 天,跳过
echo "$(( $(date +%s) - 6 * 86400 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "6 天不足 7 天则跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
# 距上次 7 天差 100 秒 → 300 秒容差内,必须执行
echo "$(( $(date +%s) - 7 * 86400 + 100 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "容差内视为已满 7 天" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
# 距上次 7 天差 600 秒 → 超出容差,跳过
echo "$(( $(date +%s) - 7 * 86400 + 600 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "超出容差仍判不足" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
# 状态文件内容被写坏 → 按 0 处理,立即执行
echo "garbage" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "状态文件非法时立即执行" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
is "非法内容被覆盖为数字" \
"$(grep -cE '^[0-9]+$' "$RESTART_STATE_FILE")" "1"
echo "garbage" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
guard_err=$("$GUARD_BIN" 2>&1 >/dev/null)
is "状态文件非法时不产生算术求值报错" \
"$(printf '%s' "$guard_err" | grep -c 'integer expression')" "0"
# 时间戳落在未来(RTC 错乱 / NTP 大幅向后校正)→ 必须按 0 处理立即执行,
# 否则时钟校回后 now-last 恒为负,此后永远跳过且无自愈路径
echo "$(( $(date +%s) + 30 * 86400 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "未来时间戳不导致永久跳过" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
is "未来时间戳被覆盖为当前时间" \
"$([ "$(cat "$RESTART_STATE_FILE")" -le "$(date +%s)" ] && echo yes || echo no)" "yes"
# 状态文件写不进去 → 必须跳过本轮。照常重启会让下一轮依旧读不到时间戳,
# 退化成每天硬重启且无限持续
guard_saved_state="$RESTART_STATE_FILE"
# (a) 目录建不出来:拿一个普通文件占住父目录的位置
: > "${TMP}/blocked"
RESTART_STATE_FILE="${TMP}/blocked/last-restart"
render_restart_units 7 04:00 Asia/Shanghai
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "状态目录建不出来时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
# (b) 目录能建但文件写不进去:让状态文件路径本身是个目录
mkdir -p "${TMP}/asdir/last-restart"
RESTART_STATE_FILE="${TMP}/asdir/last-restart"
render_restart_units 7 04:00 Asia/Shanghai
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "时间戳写不进去时跳过重启" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
RESTART_STATE_FILE="$guard_saved_state"
# 每日模式:距上次 1 天即执行
render_restart_units 1 04:00 Asia/Shanghai
echo "$(( $(date +%s) - 86400 ))" > "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "N=1 时满一天即执行" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "1"
# 时间戳先于重启写入:用一个必定失败的 systemctl 桩验证
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
exit 1
STUBEOF
chmod 755 "${STUB}/systemctl"
rm -f "$RESTART_STATE_FILE"
: > "$SYSTEMCTL_LOG"
"$GUARD_BIN" > /dev/null 2>&1
is "重启失败时时间戳仍已写入" "$([ -s "$RESTART_STATE_FILE" ] && echo yes)" "yes"
# 恢复正常桩,避免影响后续
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
STUBEOF
chmod 755 "${STUB}/systemctl"
echo "== configure_restart_timer =="
# 启用方向
RESTART_EVERY_DAYS=7
RESTART_TIME="04:00"
RESTART_TZ="Asia/Shanghai"
rm -f "${SYSTEMD_DIR}/xray-restart.timer" "${SYSTEMD_DIR}/xray-restart.service" "$GUARD_BIN"
: > "$SYSTEMCTL_LOG"
configure_restart_timer > /dev/null 2>&1
is "启用时渲染出 timer" \
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes"
is "启用时渲染出 guard" "$([ -x "$GUARD_BIN" ] && echo yes || echo no)" "yes"
is "启用时执行 daemon-reload" \
"$(grep -c '^systemctl daemon-reload$' "$SYSTEMCTL_LOG")" "1"
is "启用时执行 enable --now" \
"$(grep -c '^systemctl enable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1"
is "启用时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
# enable 失败时不得中断调用方:configure_restart_timer 在部署主干上被裸调用
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
case "$1" in
enable) exit 1 ;;
esac
STUBEOF
chmod 755 "${STUB}/systemctl"
: > "$SYSTEMCTL_LOG"
configure_restart_timer > /dev/null 2>&1
is "enable 失败时仍返回 0(不中断部署主干)" "$?" "0"
cat > "${STUB}/systemctl" << 'STUBEOF'
#!/bin/sh
echo "systemctl $*" >> "${SYSTEMCTL_LOG}"
STUBEOF
chmod 755 "${STUB}/systemctl"
# 关闭方向
echo "12345" > "$RESTART_STATE_FILE"
RESTART_EVERY_DAYS=0
: > "$SYSTEMCTL_LOG"
configure_restart_timer > /dev/null 2>&1
is "关闭时删除 timer" \
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
is "关闭时删除 service" \
"$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
is "关闭时删除 guard" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
is "关闭时保留状态文件" "$(cat "$RESTART_STATE_FILE")" "12345"
is "关闭时执行 disable --now" \
"$(grep -c '^systemctl disable --now xray-restart.timer$' "$SYSTEMCTL_LOG")" "1"
is "关闭时不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
echo "== apply_restart_only 安全不变式 =="
# 这一节守护本特性最核心的承诺:--restart 不重启 xray、不碰 vless 链接。
# 缺了它,谁在 apply_restart_only 里加一行 systemctl restart xray 都不会被发现。
APP_DIR="${TMP}/app"
mkdir -p "$APP_DIR"
SCRIPT_DIR="$APP_DIR"
make_app_env() {
cat > "${APP_DIR}/.env" << 'ENVEOF'
# Xray 部署配置(由 deploy.sh 自动生成)
XRAY_MODE=reality
XRAY_PORT=443
XRAY_UUID=11111111-2222-3333-4444-555555555555
# 这是用户自己加的注释,不许动
SERVER_IP=
XRAY_RESTART_EVERY_DAYS=0
XRAY_RESTART_TIME=04:00
XRAY_RESTART_TIMEZONE=Asia/Shanghai
XRAY_PUBLIC_KEY=pubkeyzzz
XRAY_SHORT_ID=abcdef0123456789
REALITY_SNI=www.apple.com
ENVEOF
}
# --- 启用:--restart 7 5:30 ---
reset_env
make_app_env
cp "${APP_DIR}/.env" "${TMP}/env.before"
RESTART_ARG_DAYS=7
RESTART_ARG_TIME=5:30
: > "$SYSTEMCTL_LOG"
apply_restart_only > /dev/null 2>&1
is "apply_restart_only 绝不重启 xray" \
"$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
is "天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=7"
is "时间归一化后写回" "$(grep '^XRAY_RESTART_TIME=' "${APP_DIR}/.env")" "XRAY_RESTART_TIME=05:30"
is ".env 只有这两行发生变化" \
"$(diff "${TMP}/env.before" "${APP_DIR}/.env" | grep -c '^[<>]')" "4"
is "用户注释原样保留" "$(grep -c '这是用户自己加的注释,不许动' "${APP_DIR}/.env")" "1"
is "UUID 未被改动" "$(grep -c '^XRAY_UUID=11111111-2222-3333-4444-555555555555$' "${APP_DIR}/.env")" "1"
is "SNI 未被改动" "$(grep -c '^REALITY_SNI=www\.apple\.com$' "${APP_DIR}/.env")" "1"
is "PublicKey 未被改动" "$(grep -c '^XRAY_PUBLIC_KEY=pubkeyzzz$' "${APP_DIR}/.env")" "1"
is "启用后 timer 已就位" \
"$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "yes"
# --- 不带位置参数:.env 必须完全不变 ---
reset_env
make_app_env
XRAY_RESTART_EVERY_DAYS=7
XRAY_RESTART_TIME=04:00
cp "${APP_DIR}/.env" "${TMP}/env.before2"
RESTART_ARG_DAYS=""
RESTART_ARG_TIME=""
: > "$SYSTEMCTL_LOG"
apply_restart_only > /dev/null 2>&1
is "不带位置参数时 .env 一字不改" \
"$(diff "${TMP}/env.before2" "${APP_DIR}/.env" > /dev/null && echo same || echo differ)" "same"
is "不带位置参数时也不重启 xray" \
"$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
# --- 关闭:--restart 0 ---
reset_env
echo "12345" > "$RESTART_STATE_FILE"
RESTART_ARG_DAYS=0
RESTART_ARG_TIME=""
: > "$SYSTEMCTL_LOG"
apply_restart_only > /dev/null 2>&1
is "关闭后 timer 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.timer" ] && echo yes || echo no)" "no"
is "关闭后 service 被删" "$([ -f "${SYSTEMD_DIR}/xray-restart.service" ] && echo yes || echo no)" "no"
is "关闭后 guard 被删" "$([ -f "$GUARD_BIN" ] && echo yes || echo no)" "no"
is "关闭后状态文件保留" "$(cat "$RESTART_STATE_FILE")" "12345"
is "关闭时天数写回 .env" "$(grep '^XRAY_RESTART_EVERY_DAYS=' "${APP_DIR}/.env")" "XRAY_RESTART_EVERY_DAYS=0"
is "关闭时也不重启 xray" "$(grep -c '^systemctl restart xray$' "$SYSTEMCTL_LOG")" "0"
echo "== print_next_restart =="
RESTART_EVERY_DAYS=7
rm -f "$RESTART_STATE_FILE"
is "无状态文件时提示下次唤醒即重启" \
"$(print_next_restart | grep -c '下次唤醒时即会重启')" "1"
now_ts=$(date +%s)
echo "$(( now_ts - 6 * 86400 ))" > "$RESTART_STATE_FILE"
is "未满 N 天时给出的是真实重启日期而非明天的唤醒时间" \
"$(print_next_restart | grep -c "下次实际重启: $(date -d "@$(( now_ts + 86400 ))" '+%Y-%m-%d')")" "1"
echo "$(( $(date +%s) - 9 * 86400 ))" > "$RESTART_STATE_FILE"
is "已满 N 天时提示下次唤醒即重启" \
"$(print_next_restart | grep -c '下次唤醒时即会重启')" "1"
echo ""
echo "通过 ${PASS},失败 ${FAIL}"
[ "$FAIL" -eq 0 ]
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# ============================================================
# 定时重启功能的真实环境验收脚本
#
# 用法(在 VPS 上,root:
# cd /opt/vps-xray && bash tests/verify-restart-on-vps.sh
#
# 为什么必须在真机上跑:本地的 62→101 项单元测试都是把生成的文件写进临时目录
# 再比对内容——它们能验证「我们写出了预期的字节」,但验证不了「systemd 认不认
# 这些字节」。TimeZone= 那个缺陷就是这么漏过去的:文件里确实有那一行,断言通过,
# 而 systemd 加载时只会记一条 Unknown key name 然后按系统本地时区解析。
#
# 本脚本只做安全操作:不升级 Xray、不重写 config.json、不重启 xray。
# 会临时改动 .env 的重启字段与三个 systemd 单元文件,结束时恢复原状。
# ============================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
bad() { FAIL=$((FAIL+1)); echo "$1"; [ -n "${2:-}" ] && echo " 实际: $2"; }
sec() { echo ""; echo "===== $* ====="; }
[ "$(id -u)" -eq 0 ] || { echo "需要 root"; exit 1; }
[ -f .env ] || { echo "找不到 .env,这台机器还没部署过"; exit 1; }
ENV_BAK="$(mktemp)"; cp .env "$ENV_BAK"
restore() {
echo ""
echo "===== 恢复原状 ====="
cp "$ENV_BAK" .env && echo " .env 已还原"
rm -f "$ENV_BAK"
bash deploy.sh --restart >/dev/null 2>&1 && echo " timer 已按还原后的 .env 重建" \
|| echo " timer 重建失败,请手工检查 systemctl list-timers xray-restart.timer"
}
trap restore EXIT
sec "0. 环境"
systemctl --version | head -1
timedatectl 2>/dev/null | grep -i "time zone" || cat /etc/timezone 2>/dev/null
sec "1. Critical-1OnCalendar 尾随时区是否被 systemd 真正接受"
# 这是全套验证里最关键的一条。旧实现用 [Timer] 段的 TimeZone=
# 而 systemd.timer 根本没有这个 key——写了会被忽略并按系统本地时区解析。
if out=$(systemd-analyze calendar "*-*-* 04:00:00 Asia/Shanghai" 2>&1); then
ok "systemd 接受带时区的日历表达式"
echo "$out" | sed 's/^/ /'
else
bad "systemd 不接受带时区的日历表达式(该 systemd 版本可能低于 242" "$out"
fi
sec "2. 启用:--restart 7 04:00"
BEFORE_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
bash deploy.sh --restart 7 04:00
AFTER_TS=$(systemctl show xray -p ActiveEnterTimestamp --value)
[ "$BEFORE_TS" = "$AFTER_TS" ] \
&& ok "xray 未被重启(ActiveEnterTimestamp 前后一致)" \
|| bad "xray 被重启了!--restart 不该碰服务" "before=$BEFORE_TS after=$AFTER_TS"
grep -q '^XRAY_RESTART_EVERY_DAYS=7$' .env && ok ".env 天数已写入" || bad ".env 天数未写入" "$(grep '^XRAY_RESTART_EVERY_DAYS=' .env)"
grep -q '^XRAY_RESTART_TIME=04:00$' .env && ok ".env 时间已写入" || bad ".env 时间未写入" "$(grep '^XRAY_RESTART_TIME=' .env)"
sec "3. 生成的 timer 是否被 systemd 正确加载"
systemd-analyze verify /etc/systemd/system/xray-restart.timer 2>&1 | sed 's/^/ /'
if journalctl -b --no-pager 2>/dev/null | grep -q "Unknown key name.*xray-restart"; then
bad "journal 里有 Unknown key name 告警,说明 timer 里仍有非法指令"
else
ok "journal 无 Unknown key name 告警"
fi
CAL=$(systemctl show xray-restart.timer -p TimersCalendar --value 2>/dev/null)
echo " TimersCalendar: $CAL"
case "$CAL" in
*Asia/Shanghai*) ok "解析后的日历规格里含时区" ;;
*) bad "解析后的日历规格里没有时区——时区没生效" "$CAL" ;;
esac
grep -q '^TimeZone=' /etc/systemd/system/xray-restart.timer \
&& bad "timer 里仍有非法的 TimeZone= 指令" \
|| ok "timer 里无 TimeZone= 指令"
sec "4. 守卫脚本行为"
rm -f /var/lib/xray/last-restart
echo " -- 首次(无状态文件)应执行重启 --"
/usr/local/bin/xray-restart-guard 2>&1 | sed 's/^/ /'
[ -s /var/lib/xray/last-restart ] && ok "时间戳已写入" || bad "时间戳未写入"
echo " -- 第二次(刚重启过)应跳过 --"
G2=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G2" | sed 's/^/ /'
echo "$G2" | grep -q "跳过" && ok "正确跳过" || bad "未跳过" "$G2"
echo " -- Important-4:时间戳落在未来时应视为 0 并执行重启 --"
TS_BEFORE_FUTURE=$(systemctl show xray -p ActiveEnterTimestamp --value)
echo "$(( $(date +%s) + 86400*365 ))" > /var/lib/xray/last-restart
G3=$(/usr/local/bin/xray-restart-guard 2>&1); echo "$G3" | sed 's/^/ /'
if echo "$G3" | grep -q "跳过"; then
bad "未来时间戳导致永久跳过——保护未生效"
else
ok "未来时间戳被正确当成 0"
fi
echo " (这一步会真的重启一次 xray,是预期内的)"
sleep 2
systemctl is-active --quiet xray && ok "重启后 xray 仍然存活" || bad "xray 未能重新启动!"
sec "5. Important-6:下次实际重启时间是否被正确显示"
echo "$(( $(date +%s) - 3*86400 ))" > /var/lib/xray/last-restart
OUT=$(bash deploy.sh --restart 2>&1); echo "$OUT" | sed 's/^/ /'
echo " (N=7、距上次 3 天,所以真实的下次重启应在 4 天后,而非次日)"
sec "6. 关闭:--restart 0"
bash deploy.sh --restart 0 >/dev/null 2>&1
for f in /etc/systemd/system/xray-restart.timer /etc/systemd/system/xray-restart.service /usr/local/bin/xray-restart-guard; do
[ -f "$f" ] && bad "未删除: $f" || ok "已删除: $(basename "$f")"
done
[ -f /var/lib/xray/last-restart ] && ok "状态文件按设计保留" || bad "状态文件被误删(关闭≠卸载)"
sec "7. Critical-2:前导零天数"
bash deploy.sh --restart 09 >/dev/null 2>&1
D=$(grep '^XRAY_RESTART_EVERY_DAYS=' .env | cut -d= -f2)
[ "$D" = "9" ] && ok "09 被归一化成 9(未被当八进制)" || bad "09 未被正确归一化" "$D"
if [ -f /usr/local/bin/xray-restart-guard ]; then
grep -q '^DAYS=9$' /usr/local/bin/xray-restart-guard \
&& ok "guard 里 DAYS=9" || bad "guard 里天数不对" "$(grep '^DAYS=' /usr/local/bin/xray-restart-guard)"
OUT=$(/usr/local/bin/xray-restart-guard 2>&1)
echo "$OUT" | grep -qi "value too great\|error token" \
&& bad "guard 仍有八进制算术错误" "$OUT" \
|| ok "guard 无八进制算术错误"
fi
sec "8. 参数校验与互斥"
bash deploy.sh --restart 7 25:00 >/dev/null 2>&1; [ $? -eq 1 ] && ok "非法时间退出码 1" || bad "非法时间退出码不是 1"
bash deploy.sh --badarg >/dev/null 2>&1; [ $? -eq 1 ] && ok "未知参数退出码 1" || bad "未知参数退出码不是 1"
bash deploy.sh --help >/dev/null 2>&1; [ $? -eq 0 ] && ok "--help 退出码 0" || bad "--help 退出码不是 0"
# 注意:不能写成 `bash deploy.sh ... | grep -q ...`。本脚本开了 pipefail
# 而 deploy.sh 在这里本就该以退出码 1 结束,管道会因此被判为失败——
# 于是断言恒为「未生效」,与被测行为无关。必须先取输出再匹配。
MX_OUT=$(bash deploy.sh --restart 7 --redetect 2>&1 || true)
echo "$MX_OUT" | grep -q "不能与" && ok "互斥校验生效" || bad "互斥校验未生效" "$MX_OUT"
echo ""
echo "============================================================"
echo " 通过 ${PASS},失败 ${FAIL}"
echo "============================================================"
echo ""
echo "还需要你手动做一步(本脚本刻意不做,因为它会重启服务、断掉所有连接):"
echo ""
echo " # 旧字段迁移验证 —— 建议挑一台当前没在用的机器"
echo " cp .env /tmp/env.bak"
echo " sed -i '/^XRAY_RESTART_/d' .env"
echo " echo 'XRAY_DAILY_RESTART=true' >> .env"
echo " bash deploy.sh # 完整部署,会重启 xray"
echo " grep -E '^XRAY_RESTART_|^XRAY_DAILY' .env"
echo " # 预期:出现 XRAY_RESTART_EVERY_DAYS=1,且 XRAY_DAILY_RESTART 已消失"
echo " cp /tmp/env.bak .env && bash deploy.sh --restart"
echo ""
[ "$FAIL" -eq 0 ]
+38 -5
View File
@@ -83,11 +83,36 @@ else
fi
systemctl disable xray 2>/dev/null || true
# 每日重启 timer(旧版部署可能残留)
if [ -f /etc/systemd/system/xray-restart.timer ] \
|| [ -f /usr/local/bin/xray-restart-guard ]; then
systemctl disable --now xray-restart.timer 2>/dev/null || true
rm -f /etc/systemd/system/xray-restart.timer \
/etc/systemd/system/xray-restart.service \
/usr/local/bin/xray-restart-guard \
/var/lib/xray/last-restart
log "已移除定时重启 timer 与状态文件"
fi
# ===== 2. 卸载 Xray =====
log "正在卸载 Xray..."
if [ -f /usr/local/bin/xray ]; then
# 使用官方卸载方式
bash <(curl -fsSL https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh) --remove 2>/dev/null || true
# 官方卸载:下载到临时文件再执行。
# 不能用 bash <(curl ...)——curl 缺失时进程替换只产生空脚本,
# 外层 bash 仍返回 0,卸载会被静默跳过
if command -v curl &>/dev/null; then
installer="$(mktemp)"
if curl -fsSL --max-time 60 \
https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \
-o "$installer" && [ -s "$installer" ]; then
bash "$installer" remove --purge || warn "官方卸载脚本执行失败,改为手动清理"
else
warn "无法下载官方卸载脚本,改为手动清理"
fi
rm -f "$installer"
else
warn "未安装 curl,跳过官方卸载脚本,改为手动清理"
fi
# 手动清理残留
rm -f /usr/local/bin/xray
rm -rf /usr/local/etc/xray
@@ -128,9 +153,17 @@ fi
echo ""
read -r -p "是否删除部署目录 ${SCRIPT_DIR}(y/N): " del_deploy
if [[ "$del_deploy" =~ ^[Yy]$ ]]; then
cd /opt
rm -rf "$SCRIPT_DIR"
log "已删除部署目录"
# 删除前做路径断言,避免 SCRIPT_DIR 异常时误删根目录或系统目录
case "$SCRIPT_DIR" in
/|/root|/etc|/usr|/var|/home|/opt|"")
error "拒绝删除高危路径: '${SCRIPT_DIR}',请手动处理"
;;
*)
cd /
rm -rf "$SCRIPT_DIR"
log "已删除部署目录: ${SCRIPT_DIR}"
;;
esac
else
warn "保留部署目录: ${SCRIPT_DIR}"
fi
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 仅上传服务端所需文件,跳过文档和客户端配置
set -euo pipefail
VPS_IP="${1:-}"
SSH_PORT="${2:-22}"
if [[ -z "$VPS_IP" ]]; then
echo "用法: bash upload.sh <VPS_IP> [SSH_PORT]"
echo "示例: bash upload.sh 1.2.3.4 # 使用默认端口 22"
echo "示例: bash upload.sh 1.2.3.4 2222 # 使用自定义端口 2222"
exit 1
fi
if ! [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || (( SSH_PORT < 1 || SSH_PORT > 65535 )); then
echo "错误: 端口必须是 1-65535 之间的数字,当前为 '${SSH_PORT}'"
exit 1
fi
REMOTE_DIR="/opt/vps-xray"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo ">>> 创建远程目录 ${REMOTE_DIR} (端口 ${SSH_PORT})"
ssh -p "${SSH_PORT}" "root@${VPS_IP}" "mkdir -p ${REMOTE_DIR}"
echo ">>> 上传部署文件"
scp -P "${SSH_PORT}" \
"${SCRIPT_DIR}/deploy.sh" \
"${SCRIPT_DIR}/uninstall.sh" \
"${SCRIPT_DIR}/backup.sh" \
"${SCRIPT_DIR}/.env.example" \
"root@${VPS_IP}:${REMOTE_DIR}/"
echo ""
echo "上传完成,文件位于 root@${VPS_IP}:${REMOTE_DIR}"
echo ""
echo "下一步:"
if [[ "$SSH_PORT" == "22" ]]; then
echo " ssh root@${VPS_IP}"
else
echo " ssh -p ${SSH_PORT} root@${VPS_IP}"
fi
echo " cd ${REMOTE_DIR} && bash deploy.sh"
-48
View File
@@ -1,48 +0,0 @@
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
dns:
enable: true
ipv6: false
enhanced-mode: fake-ip
nameserver:
- 223.5.5.5
- 119.29.29.29
fallback:
- 8.8.8.8
- 1.1.1.1
fallback-filter:
geoip: true
geoip-code: CN
proxies:
- name: "VPS-Reality"
type: vless
server: 45.32.69.133
port: 443
uuid: e02bfa4e-8c3c-4625-a0dc-207dd37bffd8
network: tcp
tls: true
udp: true
flow: xtls-rprx-vision
servername: www.microsoft.com
client-fingerprint: chrome
reality-opts:
public-key: J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY
short-id: 0def0ee07b3e5f05
proxy-groups:
- name: "Proxy"
type: select
proxies:
- VPS-Reality
- DIRECT
rules:
# 国内直连
- GEOIP,CN,DIRECT
- GEOSITE,cn,DIRECT
# 其余走代理
- MATCH,Proxy
-30
View File
@@ -1,30 +0,0 @@
==========================================================
Xray VLESS-Reality 部署完成 ✅ 运行中
==========================================================
IP : 45.32.69.133
端口 : 443
协议 : VLESS
UUID : e02bfa4e-8c3c-4625-a0dc-207dd37bffd8
流控 : xtls-rprx-vision
传输 : tcp
安全 : reality
SNI : www.microsoft.com
Fingerprint : chrome
PublicKey : J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY
ShortId : 0def0ee07b3e5f05
==========================================================
>>> VLESS 分享链接(可直接导入客户端):
vless://e02bfa4e-8c3c-4625-a0dc-207dd37bffd8@45.32.69.133:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.microsoft.com&fp=chrome&pbk=J0Q5maB5UZVzgAH1V6nGMHKuaaT4mNQRKMS5ztROKnY&sid=0def0ee07b3e5f05&type=tcp#VPS-Reality
==========================================================
⚠️ 请妥善保存以上信息!UUID 和密钥仅在此处显示一次。
配置文件位置: /usr/local/etc/xray/config.json
查看日志: journalctl -u xray -f
重启服务: systemctl restart xray
==========================================================
-42
View File
@@ -1,42 +0,0 @@
# =============================================================
# Windows 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
CLAUDE_MODEL=claude-sonnet-4-6
# ── Godot MCP Pro ─────────────────────────────────────────────
# 本地包相对路径(相对于本脚本目录),默认引用 claude-dev-stack 中的包
# 通常无需修改
GODOT_MCP_VERSION=godot-mcp-pro-v1.14.1
GODOT_MCP_PACKAGE_DIR=.
# ── Unity MCP ─────────────────────────────────────────────────
# unity-mcp-server 安装目录(Windows 侧)
# UNITY_MCP_INSTALL_DIR=%USERPROFILE%\unity-mcp-server
# Unity Hub 路径(写入 MCP 配置 env 供 unity-mcp 使用)
# UNITY_HUB_PATH=C:\Program Files\Unity Hub\Unity Hub.exe
# Unity MCP Bridge 端口
BRIDGE_PORT=7890
# ── Rust / Token Killer ──────────────────────────────────────
# 国内加速镜像(安装 Rust 缓慢时启用)
# RUSTUP_DIST_SERVER=https://rsproxy.cn
# RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup
-2
View File
@@ -1,2 +0,0 @@
.env
node_modules/
-155
View File
@@ -1,155 +0,0 @@
# Windows Dev Stack
**Windows 原生** Claude Code CLI + GitHub Copilot CLI + MCP + Rust Token Killer 一键部署方案。
不依赖 WSL2,所有组件直接运行在 Windows 本机。
## 组件清单
| 组件 | 说明 | 安装位置 |
|------|------|---------|
| **Node.js LTS** | JS 运行时 | winget → 系统 |
| **Claude Code CLI** | `@anthropic-ai/claude-code` | npm global |
| **GitHub CLI + Copilot CLI** | `gh` + `github/gh-copilot` 扩展 | winget + gh extension |
| **Unity MCP Server** | AnkleBreaker-Studio — Claude ↔ Unity Editor | `%USERPROFILE%\unity-mcp-server\` |
| **Godot MCP Pro** | 本地包 v1.14.1 — Claude ↔ Godot Editor | `%USERPROFILE%\godot-mcp-pro\` |
| **Rust** | rustup stable 工具链 | winget → `%USERPROFILE%\.cargo\` |
| **RTK** | Rust Token Killer — LLM token 优化 | cargo install → `%USERPROFILE%\.cargo\bin\` |
## 目录结构
```
windows-dev-stack/
├── deploy.ps1 # Windows 一键部署(PowerShell 5.1+
├── .env.example # 配置模板
└── README.md
```
> Godot MCP Pro 本地包位于 `../claude-dev-stack/godot-mcp-pro-v1.14.1/`
> 两个 stack 共用同一份本地包,无需重复存储。
---
## 快速开始
```powershell
# 1. 复制并填写配置
cd path\to\windows-dev-stack
cp .env.example .env
notepad .env
# 2. 运行部署(普通用户即可;防火墙步骤会弹 UAC 提权)
pwsh .\deploy.ps1
# 3. GitHub CLI 登录(首次使用前)
gh auth login
```
---
## 配置说明(`.env`
### 推荐:灵眸 AI(国内直连,无需代理)
| 变量 | 说明 |
|------|------|
| `ANTHROPIC_AUTH_TOKEN` | 灵眸 API Key,从 [lmuai.com](https://lmuai.com) 获取 |
| `ANTHROPIC_BASE_URL` | `https://api.lmuai.com`(默认) |
| `CLAUDE_MODEL` | `claude-sonnet-4-6`(默认) |
### 备选:Anthropic 官方 API
| 变量 | 说明 |
|------|------|
| `ANTHROPIC_API_KEY` | 从 [console.anthropic.com](https://console.anthropic.com) 获取 |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
---
## 与 claude-dev-stack 的对比
| 特性 | `claude-dev-stack` | `windows-dev-stack` |
|------|--------------------|---------------------|
| 运行环境 | WSL2Linux | Windows 原生 |
| Claude Code CLI | WSL2 内 npm global | Windows npm global |
| GitHub Copilot CLI | WSL2 gh extension | Windows gh extension |
| Rust / RTK | WSL2 cargo | Windows cargo |
| MCP Servers | WSL2 node 进程 | Windows node 进程 |
| 适合场景 | Linux 工具链 / 命令行重度用户 | 纯 Windows 开发者 / 不想装 WSL2 |
两套 stack **可以共存**,各自写入独立配置,互不干扰。
---
## MCP 配置覆盖范围
`deploy.ps1` 自动写入以下客户端的 MCP 配置:
| 客户端 | 配置文件 / 方式 |
|--------|----------------|
| Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json``mcpServers` 格式) |
| Cursor | `%USERPROFILE%\.cursor\mcp.json``mcpServers` 格式) |
| Windsurf | `%APPDATA%\Windsurf\mcp_config.json``mcpServers` 格式) |
| **VS Code Copilot Agent** | `%APPDATA%\Code\User\settings.json``mcp.servers`VS Code 专用格式) |
| Claude Code CLI | `claude mcp add --scope user`(写入用户级 MCP |
---
## RTK 使用
RTK 通过 Claude Code 的 PreToolUse hook 自动生效(`deploy.ps1` 运行 `rtk init -g` 注册)。
```powershell
# 查看 token 节省统计
rtk gain
rtk gain --history
# 手动使用
rtk git status
rtk cargo test
```
`.github/copilot-instructions.md` 已在仓库根目录配置,GitHub Copilot (VS Code) 会自动读取并在终端命令前加 `rtk` 前缀。
---
## 故障排查
### Node.js 安装后找不到命令
```powershell
# 刷新当前会话 PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" +
[System.Environment]::GetEnvironmentVariable("Path","User")
# 或直接重开 PowerShell
```
### Rust / cargo 安装后找不到命令
```powershell
# 手动加入 PATH(当前会话)
$env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path"
# 或重开终端(rustup installer 已写入用户 PATH
```
### rtk 安装失败(需要 Build Tools
```powershell
# 安装 Visual C++ Build ToolsRust Windows 编译器后端依赖)
winget install Microsoft.VisualStudio.2022.BuildTools
# 安装后重开终端,再运行:
cargo install --git https://github.com/rtk-ai/rtk
```
### gh copilot 找不到扩展
```powershell
# 确认已登录
gh auth status
# 重新安装扩展
gh extension install github/gh-copilot
```
### Unity MCP Bridge 连接失败
```powershell
# 检查 Bridge 端口
Invoke-WebRequest http://127.0.0.1:7890/api/ping
# 确认防火墙已放行
Get-NetFirewallRule -DisplayName "Unity MCP Bridge*" | Select-Object DisplayName, Enabled
```
-797
View File
@@ -1,797 +0,0 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Windows 原生 Claude Code CLI + GitHub Copilot CLI + MCP + Rust Token Killer 一键部署脚本
.DESCRIPTION
不依赖 WSL2,全部组件原生运行在 Windows 上。完成以下步骤:
1. Node.js LTS 检测 / 安装(winget
2. Claude Code CLI (@anthropic-ai/claude-code) 安装
3. GitHub CLI + Copilot CLI 扩展安装(winget + gh extension
4. Unity MCP Server 安装 & 写入各 AI 客户端 MCP 配置
5. Godot MCP Pro 安装(本地包)& 写入各 AI 客户端 MCP 配置
6. Rust 工具链 + RTK (Rust Token Killer) 安装
7. RTK hook 注册到 Claude Code + Copilot Instructions 生成
8. PowerShell Profile 快捷命令写入
.PARAMETER BridgePort
Unity MCP Bridge 端口,默认 7890
.PARAMETER SkipFirewall
跳过防火墙规则配置
.NOTES
防火墙规则需要管理员权限,其余步骤普通用户即可运行。
脚本可重复运行(幂等),已安装的组件自动跳过。
#>
param(
[int] $BridgePort = 7890,
[string]$GodotMcpVersion = "godot-mcp-pro-v1.14.1",
[switch]$SkipFirewall
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ──────────────────────────────────────────────────────────────
# 颜色日志辅助
# ──────────────────────────────────────────────────────────────
function Write-Step { param($msg) Write-Host "`n==== $msg ====" -ForegroundColor Cyan }
function Write-OK { param($msg) Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Info { param($msg) Write-Host " [..] $msg" -ForegroundColor DarkGray }
function Write-Warn { param($msg) Write-Host " [!!] $msg" -ForegroundColor Yellow }
function Write-Fail { param($msg) Write-Host " [ERR] $msg" -ForegroundColor Red }
# ──────────────────────────────────────────────────────────────
# 加载 .env 配置
# ──────────────────────────────────────────────────────────────
$ScriptDir = $PSScriptRoot
$EnvFile = Join-Path $ScriptDir ".env"
$EnvExample = Join-Path $ScriptDir ".env.example"
if (-not (Test-Path $EnvFile)) {
if (Test-Path $EnvExample) {
Copy-Item $EnvExample $EnvFile
Write-Warn "已从 .env.example 创建 .env,请按需填写后重新运行"
Write-Warn " notepad `"$EnvFile`""
exit 0
}
}
$Cfg = @{}
if (Test-Path $EnvFile) {
Get-Content $EnvFile | Where-Object { $_ -match "^\s*[^#].*=" } | ForEach-Object {
$parts = $_ -split "=", 2
$key = $parts[0].Trim()
$val = $parts[1].Trim().Trim('"').Trim("'")
$Cfg[$key] = $val
}
Write-Info "已加载配置:$EnvFile"
}
$ANTHROPIC_API_KEY = $Cfg["ANTHROPIC_API_KEY"] ?? ""
$ANTHROPIC_AUTH_TOKEN = $Cfg["ANTHROPIC_AUTH_TOKEN"] ?? ""
$ANTHROPIC_BASE_URL = $Cfg["ANTHROPIC_BASE_URL"] ?? "https://api.lmuai.com"
$CLAUDE_MODEL = $Cfg["CLAUDE_MODEL"] ?? "claude-sonnet-4-6"
$BRIDGE_PORT_CFG = $Cfg["BRIDGE_PORT"] ?? "$BridgePort"
$UnityHubPath = $Cfg["UNITY_HUB_PATH"] ?? "C:\Program Files\Unity Hub\Unity Hub.exe"
$UnityMcpInstallDir = $Cfg["UNITY_MCP_INSTALL_DIR"] ?? "$env:USERPROFILE\unity-mcp-server"
$GodotMcpPackageDir = $Cfg["GODOT_MCP_PACKAGE_DIR"] ?? "."
# 参数 BridgePort 优先于 .env
if ($BridgePort -eq 7890 -and $BRIDGE_PORT_CFG -ne "7890") { $BridgePort = [int]$BRIDGE_PORT_CFG }
# 判断认证方式
$UseLmuAuth = ($ANTHROPIC_AUTH_TOKEN -ne "" -and $ANTHROPIC_BASE_URL -ne "https://api.anthropic.com")
# ──────────────────────────────────────────────────────────────
# Banner
# ──────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ Windows Native: Claude Code + Copilot CLI + MCP + RTK ║" -ForegroundColor Cyan
Write-Host "║ Unity MCP · Godot MCP Pro · Rust Token Killer ║" -ForegroundColor Cyan
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
# ──────────────────────────────────────────────────────────────
# 辅助:刷新当前进程 PATH
# ──────────────────────────────────────────────────────────────
function Refresh-Path {
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
[System.Environment]::GetEnvironmentVariable("Path", "User")
}
# ──────────────────────────────────────────────────────────────
# 辅助:winget 安装(静默)
# ──────────────────────────────────────────────────────────────
function Install-Winget {
param([string]$Id, [string]$DisplayName)
# pwsh (Core) 里 winget 不在 PATH,尝试固定路径
$wgPath = (Get-Command winget -ErrorAction SilentlyContinue)?.Source
if (-not $wgPath) {
$wgPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
if (-not (Test-Path $wgPath)) { $wgPath = $null }
}
if (-not $wgPath) {
Write-Warn "未找到 winget,请手动安装 $DisplayName"
return $false
}
Write-Info "winget install $Id ..."
& $wgPath install --id $Id --silent --accept-source-agreements --accept-package-agreements 2>&1 | Out-Null
Refresh-Path
return $true
}
# ──────────────────────────────────────────────────────────────
# 辅助:写入 / 合并 MCP 配置文件(JSON)
# ──────────────────────────────────────────────────────────────
function Merge-McpConfig {
param(
[string] $ConfigFile,
[hashtable] $Entry,
[string] $Label,
[string] $ServerName = "unity-mcp"
)
$dir = Split-Path $ConfigFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
function ConvertTo-Hashtable($obj) {
if ($obj -is [System.Management.Automation.PSCustomObject]) {
$h = @{}
foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = ConvertTo-Hashtable $p.Value }
return $h
} elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
return @($obj | ForEach-Object { ConvertTo-Hashtable $_ })
}
return $obj
}
$cfg = @{ mcpServers = @{} }
if (Test-Path $ConfigFile) {
try {
$raw = Get-Content $ConfigFile -Raw | ConvertFrom-Json
$converted = ConvertTo-Hashtable $raw
if ($converted -is [hashtable]) { $cfg = $converted }
} catch {}
}
if (-not $cfg.ContainsKey("mcpServers")) { $cfg["mcpServers"] = @{} }
$cfg["mcpServers"][$ServerName] = $Entry
$cfg | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile -Encoding UTF8
Write-OK "$Label -> $ConfigFile"
}
# ──────────────────────────────────────────────────────────────
# 辅助:写入 VS Code settings.json 中的 mcp.serversVS Code 专用格式)
# VS Code Copilot Agent 读取 settings.json -> "mcp" -> "servers"
# 而非 mcp.json 中的 "mcpServers"(那是 Claude Desktop / Cursor 格式)
# ──────────────────────────────────────────────────────────────
function Merge-VsCodeMcpSettings {
param(
[string] $ServerName,
[hashtable] $Entry,
[string] $Label
)
$settingsFile = "$env:APPDATA\Code\User\settings.json"
$dir = Split-Path $settingsFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
function ConvertTo-OrderedHashtable($obj) {
if ($obj -is [System.Management.Automation.PSCustomObject]) {
$h = [ordered]@{}
foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = ConvertTo-OrderedHashtable $p.Value }
return $h
} elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
return @($obj | ForEach-Object { ConvertTo-OrderedHashtable $_ })
}
return $obj
}
$cfg = [ordered]@{}
if (Test-Path $settingsFile) {
try {
$raw = Get-Content $settingsFile -Raw | ConvertFrom-Json
$cfg = ConvertTo-OrderedHashtable $raw
} catch {
Write-Warn "settings.json 解析失败,将新建 mcp 节点:$_"
}
}
if (-not $cfg.ContainsKey("mcp")) { $cfg["mcp"] = [ordered]@{} }
if (-not $cfg["mcp"].ContainsKey("servers")) { $cfg["mcp"]["servers"] = [ordered]@{} }
$cfg["mcp"]["servers"][$ServerName] = $Entry
$cfg | ConvertTo-Json -Depth 15 | Set-Content $settingsFile -Encoding UTF8
Write-OK "$Label -> $settingsFile [mcp.servers.$ServerName]"
}
# ══════════════════════════════════════════════════════════════
# Step 1: Node.js LTS
# ══════════════════════════════════════════════════════════════
Write-Step "1/8 Node.js LTS"
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd) {
Write-OK "Node.js 已安装: $(& node --version)"
} else {
Write-Info "通过 winget 安装 Node.js LTS..."
$ok = Install-Winget "OpenJS.NodeJS.LTS" "Node.js LTS"
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd) {
Write-OK "Node.js 安装完成: $(& node --version)"
} else {
Write-Warn "未能自动安装 Node.js,请手动安装后重新运行:"
Write-Warn " https://nodejs.org/zh-cn/download 或 winget install OpenJS.NodeJS.LTS"
}
}
# ══════════════════════════════════════════════════════════════
# Step 2: Claude Code CLI
# ══════════════════════════════════════════════════════════════
Write-Step "2/8 Claude Code CLI"
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
if ($claudeCmd) {
Write-OK "Claude Code 已安装: $(& claude --version 2>&1 | Select-Object -First 1)"
} else {
if (Get-Command node -ErrorAction SilentlyContinue) {
Write-Info "安装 @anthropic-ai/claude-code ..."
& npm install -g @anthropic-ai/claude-code --quiet 2>&1 | Out-Null
Refresh-Path
$claudeCmd = Get-Command claude -ErrorAction SilentlyContinue
if ($claudeCmd) {
Write-OK "Claude Code 安装完成: $(& claude --version 2>&1 | Select-Object -First 1)"
} else {
Write-Warn "安装后未找到 claude 命令,请重开终端后重试"
}
} else {
Write-Warn "Node.js 未就绪,跳过 Claude Code 安装"
}
}
# ── 写入 Claude Code 配置(settings.json)──────────────────
$mcpAllowedTools = @(
"Bash","Read","Write","Edit","MultiEdit","Glob","Grep","LS","WebFetch","TodoRead","TodoWrite",
# Unity MCP
"mcp__unity-mcp__unity_editor_ping","mcp__unity-mcp__unity_editor_state","mcp__unity-mcp__unity_project_info",
"mcp__unity-mcp__unity_scene_info","mcp__unity-mcp__unity_scene_open","mcp__unity-mcp__unity_scene_save",
"mcp__unity-mcp__unity_scene_new","mcp__unity-mcp__unity_scene_hierarchy","mcp__unity-mcp__unity_scene_stats",
"mcp__unity-mcp__unity_gameobject_create","mcp__unity-mcp__unity_gameobject_delete",
"mcp__unity-mcp__unity_gameobject_info","mcp__unity-mcp__unity_gameobject_set_transform",
"mcp__unity-mcp__unity_gameobject_duplicate","mcp__unity-mcp__unity_gameobject_set_active",
"mcp__unity-mcp__unity_gameobject_reparent",
"mcp__unity-mcp__unity_component_add","mcp__unity-mcp__unity_component_remove",
"mcp__unity-mcp__unity_component_get_properties","mcp__unity-mcp__unity_component_set_property",
"mcp__unity-mcp__unity_component_set_reference","mcp__unity-mcp__unity_component_batch_wire",
"mcp__unity-mcp__unity_component_get_referenceable",
"mcp__unity-mcp__unity_asset_list","mcp__unity-mcp__unity_asset_import",
"mcp__unity-mcp__unity_asset_delete","mcp__unity-mcp__unity_asset_create_prefab",
"mcp__unity-mcp__unity_asset_instantiate_prefab",
"mcp__unity-mcp__unity_script_create","mcp__unity-mcp__unity_script_read",
"mcp__unity-mcp__unity_script_update","mcp__unity-mcp__unity_execute_code",
"mcp__unity-mcp__unity_material_create","mcp__unity-mcp__unity_renderer_set_material",
"mcp__unity-mcp__unity_build","mcp__unity-mcp__unity_play_mode",
"mcp__unity-mcp__unity_console_log","mcp__unity-mcp__unity_console_clear",
"mcp__unity-mcp__unity_get_compilation_errors",
"mcp__unity-mcp__unity_execute_menu_item","mcp__unity-mcp__unity_undo",
"mcp__unity-mcp__unity_redo","mcp__unity-mcp__unity_undo_history",
"mcp__unity-mcp__unity_selection_get","mcp__unity-mcp__unity_selection_set",
"mcp__unity-mcp__unity_selection_focus_scene_view","mcp__unity-mcp__unity_selection_find_by_type",
"mcp__unity-mcp__unity_search_by_component","mcp__unity-mcp__unity_search_by_tag",
"mcp__unity-mcp__unity_search_by_layer","mcp__unity-mcp__unity_search_by_name",
"mcp__unity-mcp__unity_search_assets","mcp__unity-mcp__unity_search_missing_references",
"mcp__unity-mcp__unity_screenshot_game","mcp__unity-mcp__unity_screenshot_scene",
"mcp__unity-mcp__unity_graphics_scene_capture","mcp__unity-mcp__unity_graphics_game_capture",
"mcp__unity-mcp__unity_prefab_info","mcp__unity-mcp__unity_set_object_reference",
"mcp__unity-mcp__unity_packages_list","mcp__unity-mcp__unity_packages_add",
"mcp__unity-mcp__unity_packages_remove","mcp__unity-mcp__unity_packages_search",
"mcp__unity-mcp__unity_packages_info",
"mcp__unity-mcp__unity_queue_info","mcp__unity-mcp__unity_agents_list","mcp__unity-mcp__unity_agent_log",
"mcp__unity-mcp__unity_list_advanced_tools","mcp__unity-mcp__unity_advanced_tool",
"mcp__unity-mcp__unity_hub_list_editors","mcp__unity-mcp__unity_hub_available_releases",
"mcp__unity-mcp__unity_hub_install_editor","mcp__unity-mcp__unity_hub_install_modules",
"mcp__unity-mcp__unity_hub_get_install_path","mcp__unity-mcp__unity_hub_set_install_path",
"mcp__unity-mcp__unity_list_instances","mcp__unity-mcp__unity_select_instance",
"mcp__unity-mcp__unity_get_project_context",
# Godot MCP Pro
"mcp__godot-mcp-pro__get_project_info","mcp__godot-mcp-pro__get_filesystem_tree",
"mcp__godot-mcp-pro__search_files","mcp__godot-mcp-pro__search_in_files",
"mcp__godot-mcp-pro__get_project_settings","mcp__godot-mcp-pro__get_project_statistics",
"mcp__godot-mcp-pro__uid_to_project_path","mcp__godot-mcp-pro__project_path_to_uid",
"mcp__godot-mcp-pro__get_scene_tree","mcp__godot-mcp-pro__get_scene_file_content",
"mcp__godot-mcp-pro__get_scene_exports","mcp__godot-mcp-pro__get_scene_dependencies",
"mcp__godot-mcp-pro__create_scene","mcp__godot-mcp-pro__open_scene",
"mcp__godot-mcp-pro__save_scene","mcp__godot-mcp-pro__add_scene_instance",
"mcp__godot-mcp-pro__get_node_properties","mcp__godot-mcp-pro__get_node_groups",
"mcp__godot-mcp-pro__get_signals","mcp__godot-mcp-pro__find_nodes_in_group",
"mcp__godot-mcp-pro__find_nodes_by_type","mcp__godot-mcp-pro__find_signal_connections",
"mcp__godot-mcp-pro__find_node_references",
"mcp__godot-mcp-pro__add_node","mcp__godot-mcp-pro__duplicate_node",
"mcp__godot-mcp-pro__move_node","mcp__godot-mcp-pro__rename_node",
"mcp__godot-mcp-pro__update_property","mcp__godot-mcp-pro__add_resource",
"mcp__godot-mcp-pro__set_anchor_preset",
"mcp__godot-mcp-pro__connect_signal","mcp__godot-mcp-pro__disconnect_signal",
"mcp__godot-mcp-pro__set_node_groups",
"mcp__godot-mcp-pro__batch_set_property","mcp__godot-mcp-pro__cross_scene_set_property",
"mcp__godot-mcp-pro__list_scripts","mcp__godot-mcp-pro__read_script",
"mcp__godot-mcp-pro__get_open_scripts","mcp__godot-mcp-pro__validate_script",
"mcp__godot-mcp-pro__create_script","mcp__godot-mcp-pro__edit_script",
"mcp__godot-mcp-pro__attach_script",
"mcp__godot-mcp-pro__get_editor_errors","mcp__godot-mcp-pro__get_output_log",
"mcp__godot-mcp-pro__get_editor_screenshot","mcp__godot-mcp-pro__get_editor_performance",
"mcp__godot-mcp-pro__clear_output","mcp__godot-mcp-pro__reload_plugin",
"mcp__godot-mcp-pro__reload_project",
"mcp__godot-mcp-pro__read_resource","mcp__godot-mcp-pro__get_resource_preview",
"mcp__godot-mcp-pro__create_resource","mcp__godot-mcp-pro__edit_resource",
"mcp__godot-mcp-pro__read_shader","mcp__godot-mcp-pro__get_shader_params",
"mcp__godot-mcp-pro__create_shader","mcp__godot-mcp-pro__edit_shader",
"mcp__godot-mcp-pro__assign_shader_material","mcp__godot-mcp-pro__set_shader_param",
"mcp__godot-mcp-pro__list_animations","mcp__godot-mcp-pro__get_animation_info",
"mcp__godot-mcp-pro__create_animation","mcp__godot-mcp-pro__add_animation_track",
"mcp__godot-mcp-pro__set_animation_keyframe",
"mcp__godot-mcp-pro__get_animation_tree_structure",
"mcp__godot-mcp-pro__create_animation_tree","mcp__godot-mcp-pro__add_state_machine_state",
"mcp__godot-mcp-pro__add_state_machine_transition",
"mcp__godot-mcp-pro__set_blend_tree_node","mcp__godot-mcp-pro__set_tree_parameter",
"mcp__godot-mcp-pro__get_audio_bus_layout","mcp__godot-mcp-pro__get_audio_info",
"mcp__godot-mcp-pro__add_audio_bus","mcp__godot-mcp-pro__set_audio_bus",
"mcp__godot-mcp-pro__add_audio_bus_effect","mcp__godot-mcp-pro__add_audio_player",
"mcp__godot-mcp-pro__get_input_actions","mcp__godot-mcp-pro__set_input_action",
"mcp__godot-mcp-pro__get_physics_layers","mcp__godot-mcp-pro__get_collision_info",
"mcp__godot-mcp-pro__set_physics_layers",
"mcp__godot-mcp-pro__add_mesh_instance","mcp__godot-mcp-pro__setup_lighting",
"mcp__godot-mcp-pro__set_material_3d","mcp__godot-mcp-pro__setup_environment",
"mcp__godot-mcp-pro__setup_camera_3d","mcp__godot-mcp-pro__add_gridmap",
"mcp__godot-mcp-pro__setup_collision","mcp__godot-mcp-pro__add_raycast",
"mcp__godot-mcp-pro__setup_physics_body",
"mcp__godot-mcp-pro__get_navigation_info",
"mcp__godot-mcp-pro__setup_navigation_region","mcp__godot-mcp-pro__bake_navigation_mesh",
"mcp__godot-mcp-pro__setup_navigation_agent","mcp__godot-mcp-pro__set_navigation_layers",
"mcp__godot-mcp-pro__get_particle_info",
"mcp__godot-mcp-pro__create_particles","mcp__godot-mcp-pro__set_particle_material",
"mcp__godot-mcp-pro__set_particle_color_gradient","mcp__godot-mcp-pro__apply_particle_preset",
"mcp__godot-mcp-pro__get_theme_info",
"mcp__godot-mcp-pro__create_theme","mcp__godot-mcp-pro__set_theme_color",
"mcp__godot-mcp-pro__set_theme_constant","mcp__godot-mcp-pro__set_theme_font_size",
"mcp__godot-mcp-pro__set_theme_stylebox",
"mcp__godot-mcp-pro__tilemap_get_cell","mcp__godot-mcp-pro__tilemap_get_info",
"mcp__godot-mcp-pro__tilemap_get_used_cells",
"mcp__godot-mcp-pro__tilemap_set_cell","mcp__godot-mcp-pro__tilemap_fill_rect",
"mcp__godot-mcp-pro__get_autoload","mcp__godot-mcp-pro__add_autoload",
"mcp__godot-mcp-pro__set_project_setting",
"mcp__godot-mcp-pro__find_unused_resources","mcp__godot-mcp-pro__analyze_signal_flow",
"mcp__godot-mcp-pro__analyze_scene_complexity","mcp__godot-mcp-pro__find_script_references",
"mcp__godot-mcp-pro__detect_circular_dependencies",
"mcp__godot-mcp-pro__get_performance_monitors",
"mcp__godot-mcp-pro__get_export_info","mcp__godot-mcp-pro__list_export_presets",
"mcp__godot-mcp-pro__play_scene","mcp__godot-mcp-pro__stop_scene",
"mcp__godot-mcp-pro__get_game_scene_tree","mcp__godot-mcp-pro__get_game_node_properties",
"mcp__godot-mcp-pro__get_game_screenshot","mcp__godot-mcp-pro__set_game_node_property",
"mcp__godot-mcp-pro__find_nodes_by_script",
"mcp__godot-mcp-pro__simulate_key","mcp__godot-mcp-pro__simulate_mouse_click",
"mcp__godot-mcp-pro__simulate_mouse_move","mcp__godot-mcp-pro__simulate_action",
"mcp__godot-mcp-pro__simulate_sequence",
"mcp__godot-mcp-pro__capture_frames","mcp__godot-mcp-pro__record_frames",
"mcp__godot-mcp-pro__monitor_properties","mcp__godot-mcp-pro__batch_get_properties",
"mcp__godot-mcp-pro__start_recording","mcp__godot-mcp-pro__stop_recording",
"mcp__godot-mcp-pro__replay_recording",
"mcp__godot-mcp-pro__find_ui_elements","mcp__godot-mcp-pro__click_button_by_text",
"mcp__godot-mcp-pro__wait_for_node","mcp__godot-mcp-pro__find_nearby_nodes",
"mcp__godot-mcp-pro__navigate_to","mcp__godot-mcp-pro__move_to",
"mcp__godot-mcp-pro__run_test_scenario","mcp__godot-mcp-pro__assert_node_state",
"mcp__godot-mcp-pro__assert_screen_text","mcp__godot-mcp-pro__run_stress_test",
"mcp__godot-mcp-pro__get_test_report","mcp__godot-mcp-pro__compare_screenshots"
)
$claudeSettingsJson = if ($UseLmuAuth) {
[ordered]@{
env = [ordered]@{
ANTHROPIC_BASE_URL = $ANTHROPIC_BASE_URL
ANTHROPIC_AUTH_TOKEN = $ANTHROPIC_AUTH_TOKEN
API_TIMEOUT_MS = "3000000"
CLAUDE_CODE_ATTRIBUTION_HEADER = "0"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"
}
model = $CLAUDE_MODEL
allowedTools = $mcpAllowedTools
} | ConvertTo-Json -Depth 4
} else {
[ordered]@{ model = $CLAUDE_MODEL; allowedTools = $mcpAllowedTools } | ConvertTo-Json -Depth 3
}
$claudeDir = "$env:USERPROFILE\.claude"
if (-not (Test-Path $claudeDir)) { New-Item -ItemType Directory -Path $claudeDir | Out-Null }
$claudeSettingsJson | Set-Content "$claudeDir\settings.json" -Encoding UTF8
if ($UseLmuAuth) {
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $null, "User")
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $null, "User")
[System.Environment]::SetEnvironmentVariable("CLAUDE_MODEL", $null, "User")
} elseif ($ANTHROPIC_API_KEY) {
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", $ANTHROPIC_API_KEY, "User")
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", $ANTHROPIC_BASE_URL, "User")
[System.Environment]::SetEnvironmentVariable("CLAUDE_MODEL", $CLAUDE_MODEL, "User")
}
Write-OK "Claude Code 配置已写入 $claudeDir\settings.json"
# ══════════════════════════════════════════════════════════════
# Step 3: GitHub CLI + Copilot CLI 扩展
# ══════════════════════════════════════════════════════════════
Write-Step "3/8 GitHub CLI + Copilot CLI"
$ghCmd = Get-Command gh -ErrorAction SilentlyContinue
if ($ghCmd) {
Write-OK "GitHub CLI 已安装: $(& gh --version 2>&1 | Select-Object -First 1)"
} else {
Write-Info "通过 winget 安装 GitHub CLI..."
$ok = Install-Winget "GitHub.cli" "GitHub CLI"
$ghCmd = Get-Command gh -ErrorAction SilentlyContinue
if ($ghCmd) {
Write-OK "GitHub CLI 安装完成: $(& gh --version 2>&1 | Select-Object -First 1)"
} else {
Write-Warn "GitHub CLI 安装后未找到,请重开终端或手动安装:winget install GitHub.cli"
}
}
if ($ghCmd) {
$extList = & gh extension list 2>&1 | Out-String
if ($extList -match "github/gh-copilot") {
Write-OK "GitHub Copilot CLI 扩展已安装"
} else {
Write-Info "安装 GitHub Copilot CLI 扩展..."
try {
& gh extension install github/gh-copilot 2>&1 | Out-Null
Write-OK "GitHub Copilot CLI 扩展安装完成(入口:gh copilot)"
} catch {
Write-Warn "安装失败(可能需要先 gh auth login),请手动运行:gh extension install github/gh-copilot"
}
}
}
# ══════════════════════════════════════════════════════════════
# Step 4: Unity MCP Server
# ══════════════════════════════════════════════════════════════
Write-Step "4/8 Unity MCP Server"
Write-Info "安装目录: $UnityMcpInstallDir"
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if (-not $gitCmd) {
Write-Warn "未找到 git,跳过 Unity MCP Server 安装"
Write-Warn "请安装 Git for Windowswinget install Git.Git"
$UnityMcpReady = $false
} else {
if (Test-Path "$UnityMcpInstallDir\.git") {
Write-Info "已存在,更新至最新..."
Push-Location $UnityMcpInstallDir
try { & git pull --quiet 2>&1 | Out-Null } finally { Pop-Location }
} else {
if (Test-Path $UnityMcpInstallDir) { Remove-Item $UnityMcpInstallDir -Recurse -Force }
& git clone --quiet --depth 1 https://github.com/AnkleBreaker-Studio/unity-mcp-server.git $UnityMcpInstallDir
}
if (Get-Command node -ErrorAction SilentlyContinue) {
Push-Location $UnityMcpInstallDir
try { & npm install --prefer-offline --quiet 2>&1 | Out-Null } finally { Pop-Location }
Write-OK "Unity MCP Server 就绪"
$UnityMcpReady = $true
} else {
Write-Warn "Node.js 未就绪,跳过 npm install"
$UnityMcpReady = $false
}
}
# ── 确定入口文件 ──────────────────────────────────────────────
$unityEntry = ""
if ($UnityMcpReady) {
foreach ($f in @("src/index.js","build/index.js","index.js")) {
$fp = Join-Path $UnityMcpInstallDir $f
if (Test-Path $fp) { $unityEntry = $fp -replace "\\","/"; break }
}
}
# ── 写入 MCP 配置(四大 AI 客户端 + Claude Code CLI)─────────
if ($unityEntry) {
$unityMcpEntry = @{
command = "node"
args = @($unityEntry)
env = @{ UNITY_HUB_PATH = $UnityHubPath; UNITY_BRIDGE_PORT = "$BridgePort" }
}
Merge-McpConfig "$env:APPDATA\Claude\claude_desktop_config.json" $unityMcpEntry "Claude Desktop"
Merge-McpConfig "$env:USERPROFILE\.cursor\mcp.json" $unityMcpEntry "Cursor"
$wsDirC = if (Test-Path "$env:APPDATA\Windsurf") { "$env:APPDATA\Windsurf" } else { "$env:USERPROFILE\.codeium\windsurf" }
Merge-McpConfig "$wsDirC\mcp_config.json" $unityMcpEntry "Windsurf"
Merge-VsCodeMcpSettings "unity-mcp" $unityMcpEntry "VS Code Copilot Agent (Unity MCP)"
# Claude Code CLIWindows
if (Get-Command claude -ErrorAction SilentlyContinue) {
& claude mcp remove unity-mcp --scope user 2>&1 | Out-Null
& claude mcp add --scope user unity-mcp node $unityEntry -e "UNITY_BRIDGE_PORT=$BridgePort" 2>&1 | Out-Null
Write-OK "Claude Code CLI MCP -> unity-mcp"
}
}
# ══════════════════════════════════════════════════════════════
# Step 5: Godot MCP Pro
# ══════════════════════════════════════════════════════════════
Write-Step "5/8 Godot MCP Pro"
# 解析 Godot MCP Pro 本地包路径(相对于本脚本目录)
$godotPkgBase = if ([System.IO.Path]::IsPathRooted($GodotMcpPackageDir)) {
$GodotMcpPackageDir
} else {
[System.IO.Path]::GetFullPath((Join-Path $ScriptDir $GodotMcpPackageDir))
}
$godotSrcDir = Join-Path $godotPkgBase "$GodotMcpVersion\server"
$godotWinDir = "$env:USERPROFILE\godot-mcp-pro"
if (-not (Test-Path $godotSrcDir)) {
Write-Warn "未找到 Godot MCP Pro 源目录: $godotSrcDir"
Write-Warn "请确认 .env 中 GODOT_MCP_PACKAGE_DIR 指向包含 $GodotMcpVersion\ 的目录"
$godotEntry = ""
} else {
Write-Info "同步 server/ -> $godotWinDir"
$rcArgs = @($godotSrcDir, $godotWinDir, "/E", "/XD", "node_modules",
"/NFL", "/NDL", "/NJH", "/NJS", "/nc", "/ns", "/np")
& robocopy @rcArgs 2>&1 | Out-Null
if (Get-Command node -ErrorAction SilentlyContinue) {
Push-Location $godotWinDir
try { & node build/setup.js install 2>&1 | Out-Null } finally { Pop-Location }
Write-OK "Godot MCP Pro 就绪: $godotWinDir"
} else {
Write-Warn "Node.js 未就绪,跳过 Godot MCP Pro setup"
}
$godotEntry = ($godotWinDir -replace "\\","/") + "/build/index.js"
$godotMcpEntry = @{ command = "node"; args = @($godotEntry) }
Merge-McpConfig "$env:APPDATA\Claude\claude_desktop_config.json" $godotMcpEntry "Claude Desktop (Godot)" "godot-mcp-pro"
Merge-McpConfig "$env:USERPROFILE\.cursor\mcp.json" $godotMcpEntry "Cursor (Godot)" "godot-mcp-pro"
$wsDirG = if (Test-Path "$env:APPDATA\Windsurf") { "$env:APPDATA\Windsurf" } else { "$env:USERPROFILE\.codeium\windsurf" }
Merge-McpConfig "$wsDirG\mcp_config.json" $godotMcpEntry "Windsurf (Godot)" "godot-mcp-pro"
Merge-VsCodeMcpSettings "godot-mcp-pro" $godotMcpEntry "VS Code Copilot Agent (Godot MCP Pro)"
if (Get-Command claude -ErrorAction SilentlyContinue) {
& claude mcp remove godot-mcp-pro --scope user 2>&1 | Out-Null
& claude mcp add --scope user godot-mcp-pro node $godotEntry 2>&1 | Out-Null
Write-OK "Claude Code CLI MCP -> godot-mcp-pro"
}
}
# ══════════════════════════════════════════════════════════════
# Step 6: 防火墙放行 MCP Bridge 端口
# ══════════════════════════════════════════════════════════════
Write-Step "6/8 Windows 防火墙规则"
if ($SkipFirewall) {
Write-Warn "已跳过防火墙配置(-SkipFirewall"
} else {
$portRange = "$BridgePort-$($BridgePort + 9)"
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($isAdmin) {
foreach ($dir in @("Inbound","Outbound")) {
$name = "Unity MCP Bridge $dir"
Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName $name -Direction $dir `
-LocalPort $portRange -Protocol TCP -Action Allow -Profile Any | Out-Null
Write-OK "防火墙规则: $name (TCP $portRange)"
}
} else {
Write-Warn "非管理员权限,尝试以提升权限添加防火墙规则..."
$fwCmd = @"
`$p = '$portRange'
foreach (`$d in @('Inbound','Outbound')) {
`$n = "Unity MCP Bridge `$d"
Get-NetFirewallRule -DisplayName `$n -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName `$n -Direction `$d -LocalPort `$p -Protocol TCP -Action Allow -Profile Any | Out-Null
Write-Host "OK: `$n"
}
Start-Sleep 1
"@
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($fwCmd))
$proc = Start-Process powershell.exe -ArgumentList "-NoProfile -EncodedCommand $enc" `
-Verb RunAs -Wait -PassThru -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -eq 0) {
Write-OK "防火墙规则已添加 (TCP $portRange)"
} else {
Write-Warn "防火墙配置未成功,请手动以管理员运行:New-NetFirewallRule ... -LocalPort $portRange"
}
}
}
# ══════════════════════════════════════════════════════════════
# Step 7: Rust 工具链 & RTK
# ══════════════════════════════════════════════════════════════
Write-Step "7/8 Rust 工具链 & Token Killer"
# 若 .env 配置了国内镜像,提前注入环境变量
$rustupMirror = $Cfg["RUSTUP_DIST_SERVER"] ?? ""
$rustupRoot = $Cfg["RUSTUP_UPDATE_ROOT"] ?? ""
if ($rustupMirror) { $env:RUSTUP_DIST_SERVER = $rustupMirror }
if ($rustupRoot) { $env:RUSTUP_UPDATE_ROOT = $rustupRoot }
# 先把 cargo\bin 加入 PATHrustup 通过 winget 安装后 PATH 需刷新)
$cargoBin = "$env:USERPROFILE\.cargo\bin"
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
Refresh-Path
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
if ($cargoCmd) {
# 检查是否有默认 toolchain,若没有则安装 stable
$rustcVer = & rustc --version 2>&1 | Out-String
if ($rustcVer -match "no default is configured" -or $rustcVer -match "error:") {
Write-Info "rustup 无默认 toolchain,安装 stable..."
& rustup toolchain install stable 2>&1 | Select-Object -Last 3
& rustup default stable 2>&1 | Out-Null
Refresh-Path
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
$rustcVer = & rustc --version 2>&1 | Out-String
}
Write-OK "Rust 已安装: $($rustcVer.Trim())"
} else {
# 检查 winget 是否已记录安装(避免重复安装时阻塞)
$wgPath = (Get-Command winget -ErrorAction SilentlyContinue)?.Source
if (-not $wgPath) {
$fallback = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
if (Test-Path $fallback) { $wgPath = $fallback }
}
$wgList = if ($wgPath) { & $wgPath list --id Rustlang.Rustup --accept-source-agreements 2>&1 | Out-String } else { "" }
if ($wgList -match "Rustlang.Rustup") {
Write-Info "rustup 已安装,但当前会话 PATH 未更新。已将 $cargoBin 加入 PATH"
# rustup 首次运行会初始化 stable 工具链
if (Test-Path "$cargoBin\rustup.exe") { & "$cargoBin\rustup.exe" show 2>&1 | Out-Null }
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
if ($cargoCmd) { Write-OK "Rust: $(& rustc --version 2>&1)" }
} else {
Write-Info "通过 winget 安装 Rust (rustup)..."
$ok = Install-Winget "Rustlang.Rustup" "Rustup"
if ($ok) {
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
Refresh-Path
$cargoCmd = Get-Command cargo -ErrorAction SilentlyContinue
if ($cargoCmd) {
Write-OK "Rust 安装完成: $(& rustc --version 2>&1)"
} else {
Write-Warn "Rust 安装后未找到 cargo,请关闭并重开终端后继续"
Write-Warn " 或手动安装:https://rustup.rs"
}
}
}
}
# 先把 ~/.local/bin 加入 PATH(预编译包安装目录)
$localBin = "$env:USERPROFILE\.local\bin"
if (Test-Path $localBin) { $env:Path = "$localBin;$env:Path" }
$rtkCmd = Get-Command rtk -ErrorAction SilentlyContinue
if ($rtkCmd) {
Write-OK "rtk 已安装: $(& rtk --version 2>&1)"
} else {
# 从 GitHub Releases 下载预编译包(比 cargo install 快得多,且无需编译环境)
Write-Info "安装 rtk (Rust Token Killer) - 下载预编译包..."
try {
$rtkVer = "v0.42.0"
$rtkUrl = "https://github.com/rtk-ai/rtk/releases/download/$rtkVer/rtk-x86_64-pc-windows-msvc.zip"
New-Item -ItemType Directory -Force -Path $localBin | Out-Null
$rtkZip = "$env:TEMP\rtk-windows.zip"
Invoke-WebRequest -Uri $rtkUrl -OutFile $rtkZip -UseBasicParsing
Expand-Archive -Path $rtkZip -DestinationPath $localBin -Force
Remove-Item $rtkZip -ErrorAction SilentlyContinue
$env:Path = "$localBin;$env:Path"
$rtkCmd = Get-Command rtk -ErrorAction SilentlyContinue
if ($rtkCmd) {
Write-OK "rtk 安装完成: $(& rtk --version 2>&1)"
} else {
Write-Warn "rtk 安装失败,可手动运行:cargo install --git https://github.com/rtk-ai/rtk"
}
} catch {
Write-Warn "rtk 下载失败: $_"
Write-Warn "可手动运行:cargo install --git https://github.com/rtk-ai/rtk"
}
}
# ── rtk init -g:注册 Claude Code PreToolUse hook ─────────────
if (Get-Command rtk -ErrorAction SilentlyContinue) {
Write-Info "注册 rtk hook 到 Claude Code..."
# 先禁用遥测,避免交互式确认提示阻塞管道
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& rtk telemetry disable 2>&1 | Out-Null
try {
& rtk init -g --auto-patch 2>&1 | Out-Null
Write-OK "rtk hook 已注册(重启 Claude Code 后生效)"
} catch {
Write-Warn "rtk init 异常(hook 可能已注册): $_"
} finally {
$ErrorActionPreference = $prevEAP
}
# ── rtk Copilot Instructions:写入 .github/copilot-instructions.md ──
# 使用已有的(位于仓库根 .github/),仅在缺失时生成
$copilotDir = Join-Path $ScriptDir ".." ".github"
$copilotInst = Join-Path $copilotDir "copilot-instructions.md"
if (-not (Test-Path $copilotInst)) {
Write-Info "生成 .github/copilot-instructions.md ..."
if (-not (Test-Path $copilotDir)) { New-Item -ItemType Directory -Path $copilotDir | Out-Null }
& rtk init -g --copilot 2>&1 | Out-Null
Write-OK "Copilot Instructions 已生成"
} else {
Write-OK "Copilot Instructions 已存在,跳过生成"
}
}
# ══════════════════════════════════════════════════════════════
# Step 8: PowerShell Profile 快捷命令
# ══════════════════════════════════════════════════════════════
Write-Step "8/8 PowerShell Profile"
$pwshProfile = "$env:USERPROFILE\Documents\PowerShell\Microsoft.PowerShell_profile.ps1"
$profileDir = Split-Path $pwshProfile
if (-not (Test-Path $profileDir)) { New-Item -ItemType Directory -Path $profileDir | Out-Null }
$profileBlock = @"
# Windows Dev Stack (generated by windows-dev-stack\deploy.ps1)
`$env:ANTHROPIC_BASE_URL = "$ANTHROPIC_BASE_URL"
$(if ($ANTHROPIC_API_KEY) { "`$env:ANTHROPIC_API_KEY = `"$ANTHROPIC_API_KEY`"" } else { "# ANTHROPIC_API_KEY= (配置 .env 后重新运行)" })
`$env:CLAUDE_MODEL = "$CLAUDE_MODEL"
# rtk / cargo PATH PATH
`$_localBin = "`$env:USERPROFILE\.local\bin"
if ((Test-Path `$_localBin) -and `$env:Path -notlike "*.local\bin*") { `$env:Path = "`$_localBin;`$env:Path" }
`$_cargoBin = "`$env:USERPROFILE\.cargo\bin"
if ((Test-Path `$_cargoBin) -and `$env:Path -notlike "*cargo\bin*") { `$env:Path = "`$_cargoBin;`$env:Path" }
function unity-mcp-status { Invoke-RestMethod http://127.0.0.1:$BridgePort/api/ping -ErrorAction SilentlyContinue }
#
"@
$existing = if (Test-Path $pwshProfile) { Get-Content $pwshProfile -Raw } else { "" }
if ($existing -match "Windows Dev Stack") {
# 注意:直接用 -replace 会将 $_ 等解释为正则反向引用导致内容爆炸
# 使用 MatchEvaluator scriptblock 让替换字符串按字面量处理
$blockCapture = $profileBlock
$existing = [regex]::Replace($existing, '(?ms)# ── Windows Dev Stack.*?# ─+', { $blockCapture })
Set-Content $pwshProfile $existing -Encoding UTF8
} else {
Add-Content $pwshProfile $profileBlock -Encoding UTF8
}
$policy = Get-ExecutionPolicy -Scope CurrentUser
if ($policy -in @("Restricted","Undefined")) {
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force
}
Write-OK "PowerShell Profile 已配置: $pwshProfile"
# ══════════════════════════════════════════════════════════════
# 安装总结
# ══════════════════════════════════════════════════════════════
Write-Host ""
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Green
Write-Host "║ ✅ Windows 原生全栈部署完成 ║" -ForegroundColor Green
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Green
Write-Host ""
$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { & node --version 2>&1 } else { "(未安装)" }
$claudeVer = if (Get-Command claude -ErrorAction SilentlyContinue) { & claude --version 2>&1 | Select-Object -First 1 } else { "(未安装)" }
$ghVer = if (Get-Command gh -ErrorAction SilentlyContinue) { & gh --version 2>&1 | Select-Object -First 1 } else { "(未安装)" }
$rustVer = if (Get-Command rustc -ErrorAction SilentlyContinue) { & rustc --version 2>&1 } else { "(未安装)" }
$rtkVer = if (Get-Command rtk -ErrorAction SilentlyContinue) { & rtk --version 2>&1 } else { "(未安装)" }
$items = @(
@("Node.js", $nodeVer),
@("Claude Code", $claudeVer),
@("GitHub CLI", $ghVer),
@("Rust", $rustVer),
@("rtk", $rtkVer),
@("Unity MCP", $(if ($unityEntry) { $unityEntry } else { "(未安装)" })),
@("Godot MCP Pro", $(if ($godotEntry) { $godotEntry } else { "(未安装)" })),
@("Bridge Port", "TCP $BridgePort (防火墙已放行 $BridgePort-$($BridgePort+9))")
)
foreach ($item in $items) {
$label = $item[0].PadRight(14)
Write-Host " $label $($item[1])" -ForegroundColor White
}
Write-Host ""
Write-Host " 后续手动步骤:" -ForegroundColor Yellow
Write-Host " 1. gh auth login # GitHub CLI 登录(首次)" -ForegroundColor DarkGray
Write-Host " 2. Unity Editor 安装 unity-mcp-plugin" -ForegroundColor DarkGray
Write-Host " https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git" -ForegroundColor DarkGray
Write-Host " 3. Godot 项目:复制 addons/godot_mcp/ 并在 Plugins 中启用" -ForegroundColor DarkGray
Write-Host " 源:$(Join-Path $godotPkgBase "$GodotMcpVersion\addons\godot_mcp")" -ForegroundColor DarkGray
Write-Host " 4. 重启 AI 客户端(Claude Desktop / Cursor / Windsurf / VS Code" -ForegroundColor DarkGray
Write-Host ""
@@ -1,146 +0,0 @@
# 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.
@@ -1,670 +0,0 @@
# 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
@@ -1,121 +0,0 @@
# 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`
@@ -1,42 +0,0 @@
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
@@ -1,496 +0,0 @@
# 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.
@@ -1,120 +0,0 @@
@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)
@@ -1,518 +0,0 @@
@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
@@ -1,192 +0,0 @@
@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,
})
@@ -1,298 +0,0 @@
@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)
@@ -1,601 +0,0 @@
@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,
})
@@ -1,431 +0,0 @@
@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)
@@ -1,255 +0,0 @@
@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
@@ -1,436 +0,0 @@
@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()})
@@ -1,682 +0,0 @@
@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"),
})
@@ -1,117 +0,0 @@
@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)
@@ -1,162 +0,0 @@
@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()
@@ -1,151 +0,0 @@
@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
@@ -1,472 +0,0 @@
@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)
@@ -1,706 +0,0 @@
@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)
@@ -1,612 +0,0 @@
@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)
@@ -1,757 +0,0 @@
@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)
@@ -1,69 +0,0 @@
@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)
@@ -1,390 +0,0 @@
@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,
})
@@ -1,201 +0,0 @@
@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,
})
@@ -1,398 +0,0 @@
@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)
@@ -1,680 +0,0 @@
@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,
})
@@ -1,331 +0,0 @@
@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)
@@ -1,369 +0,0 @@
@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()})
@@ -1,239 +0,0 @@
@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})
@@ -1,580 +0,0 @@
@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
@@ -1,424 +0,0 @@
@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)
@@ -1,217 +0,0 @@
@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()})
@@ -1,198 +0,0 @@
## 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
@@ -1,34 +0,0 @@
## 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)
@@ -1,7 +0,0 @@
[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"
@@ -1,183 +0,0 @@
@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)
@@ -1,271 +0,0 @@
> **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`
@@ -1,271 +0,0 @@
> **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`
@@ -1,276 +0,0 @@
> **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秒)を使用
- 長いduration(1秒以上)はオーバーシュートの原因に
- ゲームプレイのテストでは、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`
@@ -1,295 +0,0 @@
> **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`
@@ -1,271 +0,0 @@
> **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`
@@ -1,271 +0,0 @@
> **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.3–0.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`
@@ -1,271 +0,0 @@
> **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`
@@ -1,382 +0,0 @@
@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)
@@ -1,8 +0,0 @@
[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")
@@ -1,76 +0,0 @@
@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
@@ -1,203 +0,0 @@
@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
@@ -1,254 +0,0 @@
@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))
@@ -1,70 +0,0 @@
# 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 |
@@ -1,73 +0,0 @@
# 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
@@ -1,406 +0,0 @@
# 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).
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,19 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,151 +0,0 @@
---
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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,146 +0,0 @@
# 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.
@@ -1,10 +0,0 @@
#!/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
@@ -1 +0,0 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;GAMG"}
@@ -1,705 +0,0 @@
#!/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

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