# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## 项目概述

Godot MCP 是一个 Model Context Protocol (MCP) 服务器,允许 AI 助手通过标准化接口与 Godot 游戏引擎交互。支持启动编辑器、运行项目、捕获调试输出、场景管理、脚本操作等功能。

## 核心架构

### 双层架构设计

该项目采用 **Python + GDScript 混合架构**:

```
┌─────────────────────────────────────────────────────────────────┐
│                        AI 助手 (Claude/GPT)                      │
└─────────────────────────────────────────────────────────────────┘
                                │
                                │ MCP 协议
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                  Python MCP 服务器层                              │
│                  godot_mcp/server.py                             │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  FastMCP 框架 + @mcp.tool() 装饰器                        │    │
│  │  - 暴露 26 个工具函数给 AI                                │    │
│  │  - 参数验证和路径检测                                     │    │
│  │  - Godot 进程生命周期管理                                 │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘
                                │
                                │ subprocess (Godot CLI)
                                │ --headless --script
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                  GDScript 操作层                                 │
│                  godot_mcp/scripts/                              │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  godot_operations.gd (主路由)                             │    │
│  │  接收 JSON 操作请求,分发到专业模块                        │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                │                                 │
│        ┌───────────┬──────────┼──────────┬───────────┐          │
│        ▼           ▼          ▼          ▼           ▼          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│  │scene_ops │ │export_ops│ │script_ops│ │signal_ops│ │  ...   │ │
│  │  .gd     │ │  .gd     │ │  .gd     │ │  .gd     │ │        │ │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────┘ │
└─────────────────────────────────────────────────────────────────┘
```

### 模块对应表

所有 MCP 工具采用 `domain_action` 命名规范,便于 AI Agent 预测和发现工具。

| 功能域 | Python 工具函数 (MCP Tool) | GDScript 模块 | 测试文件 |
|--------|---------------------------|---------------|----------|
| **Godot** | `godot_get_version` | - | `test_basics.py` |
| **Editor** | `editor_open_project` | - | - |
| **Project** | `project_list`, `project_get_info`, `project_run`, `project_stop`, `project_get_debug_output`, `project_export`, `project_list_export_presets`, `project_setting_set`, `project_setting_get`, `project_update_uids` | `ops/project_ops.gd`, `ops/export_ops.gd` | `test_project_settings.py`, `test_export.py` |
| **Scene** | `scene_create`, `scene_save`, `scene_list`, `scene_get_tree`, `scene_instantiate` | `ops/scene_ops.gd` | `test_basics.py`, `test_scene_query.py`, `test_scene_manipulation.py` |
| **Node** | `node_add`, `node_delete`, `node_rename`, `node_reparent`, `node_duplicate`, `node_set_property`, `node_get_properties`, `node_add_to_group`, `node_load_sprite_texture` | `ops/scene_ops.gd` | `test_basics.py`, `test_scene_manipulation.py` |
| **Script** | `script_create`, `script_list`, `script_attach`, `script_detach` | `ops/script_ops.gd` | `test_script_attachment.py`, `test_scripting_resources.py` |
| **Signal** | `signal_connect`, `signal_disconnect`, `signal_get_node_signals`, `signal_get_connections` | `ops/signal_ops.gd` | `test_signals.py`, `test_signal_system.py` |
| **Resource** | `resource_list`, `resource_get_uid`, `resource_create_material`, `resource_get_material_properties`, `resource_export_mesh_library` | `ops/export_ops.gd`, `ops/material_ops.gd` | `test_scripting_resources.py`, `test_material.py` |
| **TileMap** | `tilemap_set_cell`, `tilemap_get_cell` | `ops/tilemap_ops.gd` | `test_tilemap.py` |
| **Input** | `input_setup_action`, `input_list_actions` | `ops/input_ops.gd` | `test_input.py` |

### 关键设计决策

- **无临时文件**: 所有 GDScript 操作通过捆绑脚本执行,避免创建临时脚本文件
- **JSON 通信**: Python 和 GDScript 之间通过 JSON 格式传递操作类型和参数
- **模块化**: GDScript 按功能域划分为独立模块,便于维护和扩展
- **参数命名转换**: Python 使用 snake_case,传递给 GDScript 时自动转换

---

## 完整开发流程:添加新 MCP 工具

以添加 `animation_create` 工具为例,展示完整的开发流程。

**重要**: 所有工具名必须遵循 `domain_action` 命名规范 (如 `scene_create`, `node_add`, `script_attach`)。

### 步骤 1: 在 server.py 添加 Python 工具函数

```python
# godot_mcp/server.py

@mcp.tool()
def animation_create(
    project_path: str,
    scene_path: str,
    animation_name: str,
    duration: float = 1.0,
    loop: bool = False,
) -> str:
    """Create an Animation resource and add it to a scene.

    Category: Animation

    Args:
        project_path: Path to the Godot project directory
        scene_path: Path to the scene file (relative to project)
        animation_name: Name of the animation to create
        duration: Duration of the animation in seconds
        loop: Whether the animation should loop
    """
    # 1. 参数验证
    if not project_path or not scene_path or not animation_name:
        return create_error_response("Missing required parameters")

    if not validate_path(project_path) or not validate_path(scene_path):
        return create_error_response("Invalid path")

    try:
        # 2. 检查项目有效性
        project_file = Path(project_path) / "project.godot"
        if not project_file.exists():
            return create_error_response(
                f"Not a valid Godot project: {project_path}",
                ["Ensure the path points to a directory containing a project.godot file"],
            )

        # 3. 检查场景文件是否存在
        scene_full_path = Path(project_path) / scene_path
        if not scene_full_path.exists():
            return create_error_response(
                f"Scene file does not exist: {scene_path}",
                ["Ensure the scene path is correct"],
            )

        # 4. 准备参数 (使用 camelCase,会被自动转换为 snake_case)
        params = {
            "scenePath": scene_path,
            "animationName": animation_name,
            "duration": duration,
            "loop": loop,
        }

        # 5. 执行 GDScript 操作 (注意: GDScript 操作名保持原有命名)
        stdout, stderr = asyncio.run(
            godot_server.execute_operation("create_animation", params, project_path)
        )

        # 6. 处理结果
        if stderr and "Failed to" in stderr:
            return create_error_response(
                f"Failed to create animation: {stderr}",
                ["Check if the animation parameters are valid"],
            )

        return f"Animation '{animation_name}' created successfully in scene '{scene_path}'."
    except Exception as error:
        return create_error_response(
            f"Failed to create animation: {error}",
            ["Ensure Godot is installed correctly"],
        )
```

**关键点:**
- 使用 `@mcp.tool()` 装饰器注册工具
- **工具名必须遵循 `domain_action` 格式** (如 `animation_create` 而非 `create_animation`)
- docstring 中添加 `Category: [Domain]` 标签
- 所有参数必须有类型注解
- docstring 会被 AI 读取,用于理解工具用途
- 参数使用 camelCase,`normalize_parameters()` 会自动转换为 snake_case
- 使用 `asyncio.run()` 调用 `execute_operation()`
- **GDScript 操作名可以保持原有命名** (Python 工具名与 GDScript 操作名解耦)

### 步骤 2: 在 godot_operations.gd 添加路由

```gdscript
# godot_mcp/scripts/godot_operations.gd

# 在 _init() 函数的 match 语句中添加:
match operation:
    # ... 其他操作 ...
    
    # Animation operations (新增)
    "create_animation":
        AnimationOps.create_animation_op(self, params)
    
    # ... 其他操作 ...
```

**注意:** 如果是新的功能域,需要:
1. 在文件顶部声明模块变量: `var AnimationOps: GDScript`
2. 在 `_load_modules()` 函数中加载: `AnimationOps = load(ops_dir + "animation_ops.gd")`
3. 在模块验证中添加检查

### 步骤 3: 创建 GDScript 操作模块

```gdscript
# godot_mcp/scripts/ops/animation_ops.gd

# Animation operations module
# Handles: create_animation
# Corresponds to: test_animation.py
class_name AnimationOps


## Create a new animation resource in a scene
## @param ctx: SceneTree - The main script context (provides debug_mode, utility functions)
## @param params: Dictionary - Parameters from Python:
##   - scene_path: String - Path to the scene file
##   - animation_name: String - Name of the animation
##   - duration: float - Duration in seconds
##   - loop: bool - Whether to loop
static func create_animation_op(ctx: SceneTree, params: Dictionary) -> void:
    print("Creating animation: " + params.animation_name)
    
    # 1. 规范化场景路径
    var full_scene_path: String = params.scene_path
    if not full_scene_path.begins_with("res://"):
        full_scene_path = "res://" + full_scene_path
    
    if ctx.debug_mode:
        print("[DEBUG] Scene path: " + full_scene_path)
    
    # 2. 检查场景文件是否存在
    if not FileAccess.file_exists(full_scene_path):
        printerr("Scene file does not exist: " + full_scene_path)
        ctx.quit(1)
    
    # 3. 加载场景
    var scene: PackedScene = load(full_scene_path)
    if not scene:
        printerr("Failed to load scene: " + full_scene_path)
        ctx.quit(1)
    
    var scene_root: Node = scene.instantiate()
    if ctx.debug_mode:
        print("[DEBUG] Scene loaded successfully")
    
    # 4. 创建 Animation 资源
    var animation: Animation = Animation.new()
    animation.length = params.duration if params.has("duration") else 1.0
    animation.loop_mode = Animation.LOOP_LINEAR if params.get("loop", false) else Animation.LOOP_NONE
    
    if ctx.debug_mode:
        print("[DEBUG] Animation created with duration: " + str(animation.length))
    
    # 5. 查找或创建 AnimationPlayer
    var anim_player: AnimationPlayer = scene_root.get_node_or_null("AnimationPlayer")
    if not anim_player:
        anim_player = AnimationPlayer.new()
        anim_player.name = "AnimationPlayer"
        scene_root.add_child(anim_player)
        anim_player.owner = scene_root
        if ctx.debug_mode:
            print("[DEBUG] Created new AnimationPlayer")
    
    # 6. 创建 AnimationLibrary (Godot 4.x 需要)
    var lib: AnimationLibrary = anim_player.get_animation_library("") if anim_player.has_animation_library("") else null
    if not lib:
        lib = AnimationLibrary.new()
        anim_player.add_animation_library("", lib)
    
    # 7. 添加动画到库
    lib.add_animation(params.animation_name, animation)
    if ctx.debug_mode:
        print("[DEBUG] Animation added to library")
    
    # 8. 保存场景
    var packed_scene: PackedScene = PackedScene.new()
    var result: int = packed_scene.pack(scene_root)
    
    if result == OK:
        var save_error: int = ResourceSaver.save(packed_scene, full_scene_path)
        if save_error == OK:
            print("Animation '" + params.animation_name + "' created successfully")
        else:
            printerr("Failed to save scene: " + str(save_error))
            ctx.quit(1)
    else:
        printerr("Failed to pack scene: " + str(result))
        ctx.quit(1)
```

**GDScript 模块规范:**
- 使用 `class_name` 声明类名
- 所有操作函数必须是 `static func`
- 第一个参数是 `ctx: SceneTree` (主脚本上下文)
- 第二个参数是 `params: Dictionary` (来自 Python 的参数)
- 使用 `ctx.debug_mode` 控制调试输出
- 错误时调用 `printerr()` 并 `ctx.quit(1)`

### 步骤 4: 编写测试用例

```python
# tests/test_animation.py

"""Tests for animation operations."""

import pytest
from pathlib import Path

from godot_mcp.server import animation_create


class TestAnimationCreate:
    """Test cases for the animation_create tool."""

    def test_create_basic_animation(
        self, temp_godot_project: Path, temp_scene_file: Path
    ) -> None:
        """Test creating a basic animation in a scene."""
        result = animation_create(
            project_path=str(temp_godot_project),
            scene_path=str(temp_scene_file),
            animation_name="test_animation",
        )
        
        assert "successfully" in result.lower()
        assert "test_animation" in result

    def test_create_looping_animation(
        self, temp_godot_project: Path, temp_scene_file: Path
    ) -> None:
        """Test creating a looping animation with custom duration."""
        result = animation_create(
            project_path=str(temp_godot_project),
            scene_path=str(temp_scene_file),
            animation_name="loop_animation",
            duration=2.5,
            loop=True,
        )
        
        assert "successfully" in result.lower()

    def test_animation_create_invalid_project(self) -> None:
        """Test error handling for invalid project path."""
        result = animation_create(
            project_path="/nonexistent/path",
            scene_path="test.tscn",
            animation_name="test",
        )
        
        assert "Not a valid Godot project" in result

    def test_animation_create_invalid_scene(
        self, temp_godot_project: Path
    ) -> None:
        """Test error handling for non-existent scene."""
        result = animation_create(
            project_path=str(temp_godot_project),
            scene_path="nonexistent.tscn",
            animation_name="test",
        )
        
        assert "does not exist" in result

    def test_animation_create_missing_params(self) -> None:
        """Test error handling for missing required parameters."""
        result = animation_create(
            project_path="",
            scene_path="",
            animation_name="",
        )
        
        assert "Missing required parameters" in result
```

**测试规范:**
- 使用 `conftest.py` 中的 fixtures: `temp_godot_project`, `temp_scene_file`
- 测试正常路径和错误处理
- 函数名以 `test_` 开头
- 使用类型注解

### 步骤 5: 运行测试验证

```bash
# 运行新测试
pytest tests/test_animation.py -v

# 运行所有测试确保没有破坏现有功能
pytest -v

# 如果测试失败,使用调试输出
pytest tests/test_animation.py -v -s
```

### 步骤 6: 更新文档

在 `README.md` 的功能列表中添加:

```markdown
- `create_animation` - Create animation resources in scenes
```

---

## 开发命令

### 运行和测试

```bash
# 直接运行 MCP 服务器
uv run python godot_mcp/server.py

# 使用 MCP Inspector 开发模式(交互式调试)
uv run mcp dev godot_mcp/server.py

# 运行所有测试
pytest

# 运行特定测试文件
pytest tests/test_basics.py

# 运行单个测试
pytest tests/test_basics.py::test_get_godot_version

# 显示详细输出的测试
pytest -v

# 运行带调试输出的测试
pytest -s

# 运行测试并显示覆盖率
pytest --cov=godot_mcp
```

### 依赖管理

```bash
# 同步项目依赖
uv sync

# 添加新依赖
uv add <package-name>

# 添加开发依赖
uv add --dev <package-name>
```

### 代码质量

```bash
# 使用 ruff 格式化代码
uv run ruff format godot_mcp tests

# 运行 ruff linter
uv run ruff check godot_mcp tests

# 自动修复 linter 问题
uv run ruff check --fix godot_mcp tests
```

### 手动测试 GDScript

```bash
# 直接调用 GDScript 操作 (用于调试)
/Applications/Godot.app/Contents/MacOS/Godot \
    --headless \
    --path /path/to/project \
    --script godot_mcp/scripts/godot_operations.gd \
    create_scene \
    '{"scene_path": "test.tscn", "root_node_type": "Node2D"}' \
    --debug-godot
```

---

## 代码规范

### Python 代码规范

1. **必须使用类型注解**:
   ```python
   # ✓ 正确
   def foo(data: dict[str, int], items: list[str]) -> str:
       pass
   
   # ✗ 错误
   def foo(data: dict, items: list) -> str:
       pass
   ```

2. **必须编写 docstring**:
   ```python
   def create_scene(project_path: str, scene_path: str) -> str:
       """Create a new Godot scene file.

       Args:
           project_path: Path to the Godot project directory
           scene_path: Path where the scene file will be saved

       Returns:
           Success message or error description
       """
   ```

3. **ruff 配置**:
   - 行长度限制: 100 字符
   - 目标版本: Python 3.10+

### GDScript 代码规范

1. **类型提示**: 
   ```gdscript
   # ✓ 正确
   var name: String = "default"
   func foo(param: int) -> bool:
       return true
   ```

2. **错误处理**: 
   ```gdscript
   # 使用 printerr() 记录错误,然后 quit(1)
   printerr("Failed to load scene: " + scene_path)
   ctx.quit(1)
   ```

3. **模块组织**: 新操作应添加到对应的 `ops/*.gd` 模块中,而不是 `godot_operations.gd`

4. **参数访问**: 使用 `params.has()` 检查可选参数
   ```gdscript
   var duration: float = params.duration if params.has("duration") else 1.0
   # 或
   var duration: float = params.get("duration", 1.0)
   ```

---

## 测试结构

```
tests/
├── conftest.py          # Pytest fixtures (temp_godot_project, temp_scene_file 等)
├── __init__.py
├── test_basics.py       # 基础场景操作测试
├── test_script_attachment.py
├── test_signals.py
├── test_project_settings.py
├── test_export.py
├── test_input.py
├── test_tilemap.py
├── test_material.py
└── e2e/                 # 端到端测试
    ├── conftest.py      # E2E 专用 fixtures
    └── test_e2e_*.py
```

### 常用 Fixtures

```python
# temp_godot_project: 创建临时 Godot 项目目录
def test_example(temp_godot_project: Path) -> None:
    project_path = str(temp_godot_project)

# temp_scene_file: 创建包含 Node2D 根节点的临时场景
def test_example(temp_godot_project: Path, temp_scene_file: Path) -> None:
    scene_path = str(temp_scene_file)  # "test_scene.tscn"

# temp_script_file: 创建临时 GDScript 文件
def test_example(temp_godot_project: Path, temp_script_file: Path) -> None:
    script_path = str(temp_script_file)  # "test_script.gd"

# godot_demo_projects: 克隆 Godot 官方演示项目 (session scope)
def test_example(godot_demo_projects: Path) -> None:
    demo_path = godot_demo_projects / "2d/..." 
```

---

## 数据流详解

### 参数转换流程

```
Python 调用                    GDScript 接收
─────────────────────────────────────────────────────
scenePath      ──转换──>      scene_path
nodeType       ──转换──>      node_type
parentNodePath ──转换──>      parent_node_path
```

转换由 `GodotServer.normalize_parameters()` 自动完成。

### JSON 通信格式

```json
// Python 发送 (execute_operation)
{
    "scene_path": "scenes/main.tscn",
    "node_type": "Sprite2D",
    "node_name": "Player"
}

// GDScript 返回 (通过 stdout)
// 成功: 打印消息
"Node 'Player' of type 'Sprite2D' added successfully"

// 失败: 打印到 stderr
"[ERROR] Failed to load scene: scenes/main.tscn"
```

### execute_operation 执行流程

```python
# server.py 中的 execute_operation
async def execute_operation(self, operation: str, params: dict, project_path: str):
    # 1. 检测 Godot 路径
    # 2. 转换参数为 snake_case
    # 3. 构建命令行:
    #    godot --headless --path <project> --script godot_operations.gd <operation> <json>
    # 4. 执行子进程并返回 stdout, stderr
```

---

## 环境变量

| 变量名 | 作用 | 示例 |
|--------|------|------|
| `GODOT_PATH` | 自定义 Godot 可执行文件路径 | `/usr/local/bin/godot` |
| `DEBUG` | 启用服务器端调试日志 | `true` |

---

## 常见陷阱和解决方案

### 1. Godot 路径检测失败

```bash
# 解决: 设置环境变量
export GODOT_PATH=/Applications/Godot.app/Contents/MacOS/Godot
```

### 2. JSON 解析错误

```gdscript
# 问题: Python 传递的 JSON 格式不正确
# 解决: 使用 json.dumps() 确保正确转义

# Python 端
json_params = json.dumps(params)  # 自动处理特殊字符
```

### 3. 场景路径问题

```gdscript
# GDScript 中路径必须以 res:// 开头
var full_path: String = scene_path
if not full_path.begins_with("res://"):
    full_path = "res://" + full_path
```

### 4. 节点所有权 (Owner)

```gdscript
# 添加新节点时必须设置 owner,否则保存时节点会丢失
parent.add_child(new_node)
new_node.owner = scene_root  # 关键!
```

### 5. 异步操作

```python
# server.py 中某些操作是同步执行的,使用 asyncio.run()
stdout, stderr = asyncio.run(
    godot_server.execute_operation("create_scene", params, project_path)
)
```

### 6. 资源路径 vs 绝对路径

```gdscript
# 资源路径 (Godot 内部使用)
var res_path: String = "res://scenes/main.tscn"

# 绝对路径 (文件系统操作)
var abs_path: String = ProjectSettings.globalize_path(res_path)
```

---

## 项目依赖

- **核心依赖**:
  - `mcp>=1.26.0`: FastMCP 框架

- **开发依赖**:
  - `pytest>=7.0`: 测试框架
  - `pytest-asyncio>=0.21`: 异步测试支持
  - `ruff>=0.1`: 代码格式化和 linting

---

## 相关文档

- `README.md`: 用户使用文档(英文)
- `README.zh-CN.md`: 用户使用文档(中文)
- `CONTRIBUTING.md`: 贡献指南
- `pyproject.toml`: 项目配置和依赖定义

---

## 快速检查清单

添加新工具时的检查清单:

- [ ] **命名**: 工具名必须遵循 `domain_action` 格式 (如 `scene_create`, `node_add`)
- [ ] 在 `server.py` 添加 `@mcp.tool()` 装饰的函数
- [ ] docstring 中添加 `Category: [Domain]` 标签
- [ ] 函数有完整的类型注解和 docstring
- [ ] 参数验证和错误处理完善
- [ ] 在 `godot_operations.gd` 的 match 语句中添加路由 (GDScript 操作名可不同于工具名)
- [ ] 如果是新功能域,创建新的 `ops/*.gd` 模块
- [ ] 在对应模块中实现 `static func xxx_op(ctx, params)` 函数
- [ ] 在 `tests/` 目录添加测试用例
- [ ] 运行 `pytest` 确保所有测试通过
- [ ] 运行 `uv run ruff format` 和 `uv run ruff check` 
- [ ] 更新本文档的模块对应表
- [ ] 更新 `README.md` 功能列表
