Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ jobs:
- name: Run handler tests
run: bash script/ci-godot-tests

- name: Reload smoke test
run: bash script/ci-reload-test

godot-tests-macos:
name: Godot tests / macOS
runs-on: macos-latest
Expand Down Expand Up @@ -101,6 +104,9 @@ jobs:
- name: Run handler tests
run: bash script/ci-godot-tests

- name: Reload smoke test
run: bash script/ci-reload-test

godot-tests-windows:
name: Godot tests / Windows
runs-on: windows-latest
Expand Down Expand Up @@ -134,3 +140,7 @@ jobs:
- name: Run handler tests
shell: bash
run: bash script/ci-godot-tests

- name: Reload smoke test
shell: bash
run: bash script/ci-reload-test
32 changes: 20 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@ A production-grade MCP server for Godot. Python server (FastMCP v3) communicates
## Architecture

```
AI Client → MCP (stdio/sse) → Python FastMCP server → WebSocket (port 9500) → Godot EditorPlugin
AI Client → MCP (stdio/sse/streamable-http) → Python FastMCP server → WebSocket (port 9500) → Godot EditorPlugin
```

- **Python server**: `src/godot_ai/` — FastMCP v3, async, lifespan manages WebSocket server
- **GDScript plugin**: `plugin/addons/godot_ai/` — canonical source; copied into `test_project/addons/` for testing
- **GDScript plugin**: `plugin/addons/godot_ai/` — canonical source; symlinked into `test_project/addons/` for testing
- **Protocol**: JSON over WebSocket. Request/response with `request_id` correlation. Handshake on connect.
- **Session model**: Multiple Godot editors can connect. Tools route through active session.
- **Handler/Runtime layer**: Shared handlers in `src/godot_ai/handlers/` contain tool logic. They depend on a `Runtime` protocol (`runtime/interface.py`), implemented by `DirectRuntime` for the in-process server. Tools and resources are thin wrappers that create a runtime and delegate.

## Key conventions

- **GDScript plugin is the canonical copy** in `plugin/`. `test_project/addons/godot_ai` is a symlink — no copy needed.
- **Error codes**: Defined in `protocol/errors.py` (Python) and `utils/error_codes.gd` (GDScript). Keep in sync.
- **Tools return `dict`**: `GodotClient.send()` returns `response.data` (a dict) or raises `GodotCommandError`. Tools just `return await app.client.send(...)`.
- **Tools return `dict`**: Handlers call `runtime.send_command(command, params)` which returns a dict or raises. Tools create a `DirectRuntime` and delegate to handlers.
- **Plugin runs on main thread**: All GDScript executes in `_process()` with a 4ms frame budget. Never block. Use `call_deferred` for scene tree mutations.
- **Scene paths are clean**: `/Main/Camera3D` format, not raw Godot internal paths. Use `ScenePath.from_node(node, scene_root)` in GDScript.
- **MCP logging**: Plugin prints `MCP | [recv] command(params)` / `MCP | [send] command -> ok` to Godot console. Controlled by `mcp_logging` var.
Expand All @@ -40,21 +41,26 @@ ruff format src/ tests/ # format
### Server lifecycle in dev

The plugin manages the server process:
- **Reload Plugin** in the Godot dock kills the old server, starts a new one from `.venv/bin/python -m godot_ai`
- After Reload Plugin, do `/mcp` in Claude Code to reconnect

The plugin prefers the local `.venv` over system-installed `godot-ai` so dev checkouts always use source code.
- On startup, plugin checks if port 8000 is already in use. If yes, uses existing server. If no, spawns `.venv/bin/python -m godot_ai --transport streamable-http --port 8000`.
- The plugin prefers the local `.venv` over system-installed `godot-ai` so dev checkouts always use source code.

For Python auto-reload during dev (no need to touch Godot):
```bash
python -m godot_ai --transport streamable-http --port 8000 --reload
```
This uses `src/godot_ai/asgi.py` to run uvicorn with its factory reload path. Uvicorn watches `src/` for changes and restarts the server process automatically. The plugin auto-reconnects.

### Plugin reload

The `reload_plugin` MCP tool triggers a live plugin reload inside Godot (`EditorInterface.set_plugin_enabled` off/on). Requires the server to be running externally (not managed by the plugin). The Python handler waits for the new session via `SessionRegistry.wait_for_session()`.

The Godot dock also has a **Start/Stop Dev Server** button for convenience.

## Testing

### Python tests
```bash
pytest -v # 81 unit + integration tests
pytest -v # 140 unit + integration tests
```

### Godot-side tests
Expand All @@ -78,17 +84,19 @@ Test suites extend `McpTestSuite` (assertion methods: `assert_true`, `assert_eq`

The plugin can configure MCP clients via `client_configurator.gd`:
- **Claude Code**: uses `claude mcp add` CLI to register the server
- **Codex**: writes TOML config to `~/.codex/config.toml`
- **Antigravity**: writes directly to `~/.gemini/antigravity/mcp_config.json`

MCP tools `client_configure` and `client_status` expose this to AI clients.

## Adding a new tool

1. Add a handler method in the appropriate `handlers/*.gd` file
1. Add a handler method in the appropriate GDScript `handlers/*.gd` file
2. Register it in `plugin.gd`: `_dispatcher.register("command_name", handler.method)`
3. Add a Python tool in `tools/<domain>.py` that calls `app.client.send("command_name", params)`
4. Register the tool module in `server.py` if it's a new file
5. Add tests: Python integration test in `tests/` AND GDScript test in `test_project/tests/`
3. Add a shared Python handler in `handlers/<domain>.py` that calls `runtime.send_command("command_name", params)`
4. Add a Python tool in `tools/<domain>.py` that creates `DirectRuntime` and delegates to the handler
5. Register the tool module in `server.py` if it's a new file
6. Add tests: handler unit test, Python integration test, AND GDScript test in `test_project/tests/`

## Write tools must be undoable

Expand Down
23 changes: 16 additions & 7 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,10 @@ addons/godot_ai/
├── dispatcher.gd # Command queue, frame-budget dispatch, handler routing
├── mcp_dock.gd # Editor dock panel
├── handlers/
│ ├── editor_handler.gd # editor_state, selection, logs
│ ├── editor_handler.gd # editor_state, selection, logs, reload_plugin
│ ├── scene_handler.gd # scene tree reading
│ ├── node_handler.gd # node create (with undo)
│ ├── project_handler.gd # project settings, filesystem search
│ └── client_handler.gd # client configure/status
└── utils/
├── scene_path.gd # from_node(), resolve()
Expand Down Expand Up @@ -425,7 +426,7 @@ The product should be useful for inspection and navigation before any write tool
| Reconnect button | `Button` | Calls `_attempt_reconnect()` |
| Reload Plugin button | `Button` | Toggles plugin off/on |
| Setup status | Dev mode / uv version | Auto-detected |
| Client config | Configure buttons per client | Claude Code, Antigravity |
| Client config | Configure buttons per client | Claude Code, Codex, Antigravity |

### Pagination design

Expand All @@ -450,8 +451,14 @@ Large results (scene trees with 1000+ nodes, long log buffers) need pagination:
- [x] Batch 3: Project reads (project_settings.get, filesystem.search)
- [x] Batch 4: MCP Resources (7 resources: sessions, scene/current, scene/hierarchy, selection/current, project/info, project/settings, logs/recent)
- [x] Batch 5: Editor dock panel with setup status
- [x] Batch 6: Test harness (44 Godot-side + 81 Python = 125 total tests)
- [x] Batch 6: Test harness (44 Godot-side + 140 Python = 184 total tests)
- [x] Pagination for large results (offset/limit on scene_get_hierarchy, logs_read, node_find, filesystem_search)
- [x] Handler/runtime abstraction layer (shared handlers depend on Runtime protocol, not FastMCP context)
- [x] Codex client configurator (TOML config at `~/.codex/config.toml`)
- [x] `reload_plugin` tool — triggers live plugin reload, waits for new session via Future-based waiter
- [x] ASGI reloadable entrypoint (`--reload` uses uvicorn factory path for Python auto-reload)
- [x] Dev server start/stop controls in Godot dock panel
- [x] Reload smoke test in CI (creates node, reloads plugin, verifies log buffer fresh + scene tree survived)
- [ ] Manual test: Claude describes the open scene

---
Expand Down Expand Up @@ -500,10 +507,12 @@ Run on every push and PR. Three-tier matrix:
- Runs on release tags only (not every push)

**Setup tasks:**
- [ ] Create `.github/workflows/ci.yml` with Tier 1 (Python tests)
- [ ] Add Tier 2 with Godot headless (investigate `chickensoft-games/setup-godot` action)
- [ ] Add Tier 3 for release smoke tests
- [ ] Add status badges to README
- [x] Create `.github/workflows/ci.yml` with Tier 1 (Python tests) — 6 jobs: 3 OS x 2 Python versions
- [x] Add Tier 2 with Godot headless — 3 jobs: Linux (Docker), macOS, Windows using `chickensoft-games/setup-godot`
- [x] Add reload smoke test to Tier 2 (reload_plugin e2e on all 3 OSes)
- [x] Add Codecov integration with patch coverage check
- [x] Add status badges to README
- [ ] Add Tier 3 for release smoke tests (uvx install path)

---

Expand Down
184 changes: 182 additions & 2 deletions plugin/addons/godot_ai/client_configurator.gd
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
class_name McpClientConfigurator
extends RefCounted

## Configures MCP clients (Claude Code, Antigravity, etc.) to connect to
## Configures MCP clients (Claude Code, Codex, Antigravity, etc.) to connect to
## the Godot MCP Studio server.

enum ClientType { CLAUDE_CODE, ANTIGRAVITY }
enum ClientType { CLAUDE_CODE, CODEX, ANTIGRAVITY }
enum ConfigStatus { NOT_CONFIGURED, CONFIGURED, ERROR }

const SERVER_NAME := "godot-ai"
Expand All @@ -16,6 +16,7 @@ const SERVER_HTTP_URL := "http://127.0.0.1:%d/mcp" % SERVER_HTTP_PORT
## Map client name strings to enum values.
const CLIENT_TYPE_MAP := {
"claude_code": ClientType.CLAUDE_CODE,
"codex": ClientType.CODEX,
"antigravity": ClientType.ANTIGRAVITY,
}

Expand All @@ -24,6 +25,8 @@ static func configure(client: ClientType) -> Dictionary:
match client:
ClientType.CLAUDE_CODE:
return _configure_claude_code()
ClientType.CODEX:
return _configure_codex()
ClientType.ANTIGRAVITY:
return _configure_antigravity()
return {"status": "error", "message": "Unknown client type"}
Expand All @@ -33,6 +36,8 @@ static func check_status(client: ClientType) -> ConfigStatus:
match client:
ClientType.CLAUDE_CODE:
return _check_claude_code()
ClientType.CODEX:
return _check_codex()
ClientType.ANTIGRAVITY:
return _check_antigravity()
return ConfigStatus.NOT_CONFIGURED
Expand All @@ -42,6 +47,8 @@ static func remove(client: ClientType) -> Dictionary:
match client:
ClientType.CLAUDE_CODE:
return _remove_claude_code()
ClientType.CODEX:
return _remove_codex()
ClientType.ANTIGRAVITY:
return _remove_antigravity()
return {"status": "error", "message": "Unknown client type"}
Expand Down Expand Up @@ -239,6 +246,179 @@ static func _remove_claude_code() -> Dictionary:
return {"status": "error", "message": "Failed to remove: %s" % err_msg}


# --- Codex ---

static func _get_codex_config_path() -> String:
var home := OS.get_environment("HOME")
if home.is_empty():
home = OS.get_environment("USERPROFILE")
return home.path_join(".codex/config.toml")

Comment on lines +251 to +256

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_codex_config_path() uses OS.get_environment("HOME") unconditionally. On Windows this env var is often unset, which would produce a relative path like .codex/config.toml and read/write the wrong location. Consider using a Windows fallback (e.g., USERPROFILE) and/or validating that the resolved home directory is non-empty before writing.

Copilot uses AI. Check for mistakes.

static func _codex_server_header() -> String:
return "[mcp_servers.\"%s\"]" % SERVER_NAME


static func _codex_legacy_server_header() -> String:
return "[mcp_servers.%s]" % SERVER_NAME.replace("-", "_")


static func _codex_server_prefixes() -> Array[String]:
return [
"[mcp_servers.\"%s\"" % SERVER_NAME,
"[mcp_servers.%s" % SERVER_NAME.replace("-", "_"),
]


static func _read_codex_config() -> String:
var config_path := _get_codex_config_path()
var file := FileAccess.open(config_path, FileAccess.READ)
if file == null:
return ""
var content := file.get_as_text()
file.close()
return content


static func _write_codex_config(content: String) -> bool:
var config_path := _get_codex_config_path()
DirAccess.make_dir_recursive_absolute(config_path.get_base_dir())
var file := FileAccess.open(config_path, FileAccess.WRITE)
if file == null:
return false
file.store_string(content)
file.close()
return true


static func _split_lines(content: String) -> Array[String]:
var lines: Array[String] = []
for line in content.split("\n"):
lines.append(line)
return lines


static func _find_codex_server_section(lines: Array[String]) -> Dictionary:
var headers := [_codex_server_header(), _codex_legacy_server_header()]
for i in range(lines.size()):
var trimmed := lines[i].strip_edges()
if headers.has(trimmed):
var end := lines.size()
for j in range(i + 1, lines.size()):
var next_trimmed := lines[j].strip_edges()
if next_trimmed.begins_with("[") and next_trimmed.ends_with("]"):
end = j
break
return {"start": i, "end": end}
return {}


static func _is_codex_server_section_header(trimmed: String) -> bool:
for prefix in _codex_server_prefixes():
if trimmed.begins_with(prefix):
return true
return false


static func _join_lines(lines: Array[String]) -> String:
return "\n".join(lines)


static func _configure_codex() -> Dictionary:
var content := _read_codex_config()
var lines := _split_lines(content)
var section := _find_codex_server_section(lines)
var server_lines: Array[String] = [
_codex_server_header(),
"url = \"%s\"" % SERVER_HTTP_URL,
"enabled = true",
]

if section.is_empty():
if not lines.is_empty() and not lines[-1].strip_edges().is_empty():
lines.append("")
lines.append_array(server_lines)
else:
var start: int = section["start"]
var end: int = section["end"]
var filtered_body: Array[String] = []
for i in range(start + 1, end):
var trimmed := lines[i].strip_edges()
if trimmed.begins_with("url ="):
continue
if trimmed.begins_with("enabled ="):
continue
filtered_body.append(lines[i])

var updated: Array[String] = []
updated.append_array(lines.slice(0, start))
updated.append_array(server_lines)
updated.append_array(filtered_body)
updated.append_array(lines.slice(end))
lines = updated

if not _write_codex_config(_join_lines(lines)):
return {"status": "error", "message": "Cannot write to %s" % _get_codex_config_path()}
return {"status": "ok", "message": "Codex configured (HTTP: %s)" % SERVER_HTTP_URL}


static func _check_codex() -> ConfigStatus:
var content := _read_codex_config()
if content.is_empty():
return ConfigStatus.NOT_CONFIGURED

var lines := _split_lines(content)
var section := _find_codex_server_section(lines)
if section.is_empty():
return ConfigStatus.NOT_CONFIGURED

var start: int = section["start"]
var end: int = section["end"]
var configured_url := ""
var enabled := true
for i in range(start + 1, end):
var trimmed := lines[i].strip_edges()
if trimmed.begins_with("url ="):
var first_quote := trimmed.find("\"")
var last_quote := trimmed.rfind("\"")
if first_quote >= 0 and last_quote > first_quote:
configured_url = trimmed.substr(first_quote + 1, last_quote - first_quote - 1)
elif trimmed.begins_with("enabled ="):
enabled = trimmed.to_lower().find("false") < 0

if configured_url != SERVER_HTTP_URL:
return ConfigStatus.NOT_CONFIGURED
if not enabled:
return ConfigStatus.NOT_CONFIGURED
return ConfigStatus.CONFIGURED


static func _remove_codex() -> Dictionary:
var content := _read_codex_config()
if content.is_empty():
return {"status": "ok", "message": "Not configured"}

var lines := _split_lines(content)
var updated: Array[String] = []
var i := 0
while i < lines.size():
var trimmed := lines[i].strip_edges()
if _is_codex_server_section_header(trimmed):
i += 1
while i < lines.size():
var next_trimmed := lines[i].strip_edges()
if next_trimmed.begins_with("[") and next_trimmed.ends_with("]"):
break
i += 1
continue
updated.append(lines[i])
i += 1

if not _write_codex_config(_join_lines(updated)):
return {"status": "error", "message": "Cannot write to %s" % _get_codex_config_path()}
return {"status": "ok", "message": "Codex configuration removed"}


# --- Antigravity ---

static func _get_antigravity_config_path() -> String:
Expand Down
Loading
Loading