Skip to content

Commit 20ff47e

Browse files
Clubhouse1661Joshua
andauthored
Add Godot ClassDB API introspection (#553)
* Add Godot ClassDB API introspection * Address API introspection review feedback --------- Co-authored-by: Joshua <youremail@example.com>
1 parent 45637e8 commit 20ff47e

29 files changed

Lines changed: 958 additions & 109 deletions

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ AI Client → MCP (stdio/sse/streamable-http) → Python FastMCP server → WebS
2323

2424
- `src/godot_ai/` — Python MCP server (FastMCP v3)
2525
- `server.py` — entrypoint, lifespan, tool registration, `--exclude-domains` support
26-
- `tools/` — MCP tool modules (session, editor, scene, node, project, script, resource, filesystem, signal, autoload, input_map, game, testing, batch, client, ui, theme, animation, material, particle, camera, audio) + `_meta_tool.py` (`register_manage_tool` rollup factory)
27-
- `resources/``godot://...` read-only URIs (sessions, editor, project, nodes, scripts, scenes, library)
26+
- `tools/` — MCP tool modules (session, editor, scene, node, project, script, resource, api, filesystem, signal, autoload, input_map, game, testing, batch, client, ui, theme, animation, material, particle, camera, audio) + `_meta_tool.py` (`register_manage_tool` rollup factory)
27+
- `resources/``godot://...` read-only URIs (sessions, editor, project, nodes, classes, scripts, scenes, library)
2828
- `middleware/``PreserveGodotCommandErrorData`, `StripClientWrapperKwargs`, `ParseStringifiedParams`, `HintOpTypoOnManage` (registration order is load-bearing — see the docstring above the `mcp.add_middleware(...)` calls in `server.py` and `tests/unit/test_server_middleware_order.py`)
2929
- `handlers/` — shared sync handlers using `DirectRuntime`; `_readiness.py` gates writes
3030
- `runtime/direct.py``DirectRuntime`, the in-process runtime adapter
@@ -390,7 +390,7 @@ The MCP tool surface is shaped to satisfy two pressures at once:
390390
1. **Anthropic tool-search clients** (`tool_search_tool_bm25_20251119` / `tool_search_tool_regex_20251119`) — non-core tools are tagged `meta={"defer_loading": True}` so the client only loads schemas it searches for.
391391
2. **Tool-count caps in non-search clients** (Antigravity, etc., that ignore `defer_loading` and refuse to start past ~40 tools) — long-tail verbs collapse into per-domain `<domain>_manage` rollups (`op="<verb>"` + `params` dict). Schema-aware clients still see every op via the dynamic `Literal[...]` enum built by `register_manage_tool` in `tools/_meta_tool.py`.
392392

393-
Result: ~40 MCP tools (4 core + 15 named verbs + 21 rollups), down from a flat surface that crossed 100. Plugin command names over WebSocket stay independent — they're documented in `tool_catalog.gd` and unchanged by the rollup refactor.
393+
Result: ~41 MCP tools (4 core + 15 named verbs + 22 rollups), down from a flat surface that crossed 100. Plugin command names over WebSocket stay independent — they're documented in `tool_catalog.gd` and unchanged by the rollup refactor.
394394

395395
- All tools follow `domain_action` namespacing — no ambiguous prefixes
396396
- Core tools loaded upfront (no `meta=`): `editor_state`, `scene_get_hierarchy`, `node_get_properties`, `session_activate`

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
[![Godot Asset Library](https://img.shields.io/badge/Godot-Asset%20Library-478cbf?logo=godotengine&logoColor=white)](https://godotengine.org/asset-library/asset/5050)
1010
[![Discord](https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/FDZ5fr2QkP)
1111

12-
**Connect MCP clients directly to a live Godot editor** via the [Model Context Protocol](https://modelcontextprotocol.io/introduction). Over **120 ops across ~39 MCP tools** ([full list](docs/TOOLS.md)) let AI assistants (Claude Code, Codex, Antigravity, etc.) build scenes, edit nodes and scripts, wire signals, and configure UI, materials, animations, particles, cameras, and environments.
12+
**Connect MCP clients directly to a live Godot editor** via the [Model Context Protocol](https://modelcontextprotocol.io/introduction). Over **120 ops across ~41 MCP tools** ([full list](docs/TOOLS.md)) let AI assistants (Claude Code, Codex, Antigravity, etc.) build scenes, edit nodes and scripts, wire signals, and configure UI, materials, animations, particles, cameras, and environments.
1313

1414
> 🎉 **Now on the [Godot Asset Library](https://godotengine.org/asset-library/asset/5050) and the [new Godot Asset Store](https://store.godotengine.org/asset/dlight/godot-ai/)** — one-click install from Godot's **AssetLib** tab. You'll still need [uv](https://docs.astral.sh/uv/) for the Python server (see [Quick Start](#quick-start)).
1515

docs/TOOLS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Available Tools
22

3-
Godot AI exposes ~39 MCP tools — ~18 high-traffic verbs as named tools, plus
3+
Godot AI exposes ~41 MCP tools — ~18 high-traffic verbs as named tools, plus
44
one rolled-up `<domain>_manage` per domain that takes `op="..."` + a `params`
55
dict. The rollup pattern keeps the tool count well below the 100-tool caps
66
some clients enforce while still exposing every action.
@@ -82,8 +82,17 @@ Calls take the form:
8282
| `theme_manage` | `create`, `set_color`, `set_constant`, `set_font_size`, `set_stylebox_flat`, `apply` |
8383
| `ui_manage` | `set_anchor_preset`, `set_text`, `build_layout`, `draw_recipe` |
8484
| `resource_manage` | `search`, `load`, `assign`, `get_info`, `create`, `curve_set_points`, `environment_create`, `physics_shape_autofit`, `gradient_texture_create`, `noise_texture_create` |
85+
| `api_manage` | `get_class` |
8586
| `client_manage` | `status`, `configure`, `remove` |
8687

88+
`api_manage(op="get_class")` inspects Godot API/ClassDB metadata for a class
89+
without creating an instance. By default it returns direct class members only,
90+
with each returned section capped at 100 items. Pass `sections` (`properties`,
91+
`methods`, `signals`, `enums`, `constants`, `inheritors`),
92+
`include_inherited=true`, `include_inheritors=true`, `offset`, or `limit=0`
93+
when a fuller class reference is needed. When paginating, request one section
94+
at a time so `offset`/`limit` apply only to the list you are paging.
95+
8796
Every rolled-up tool also accepts an optional top-level `session_id` for
8897
per-call multi-editor routing (sibling of `op` and `params`, *not* nested
8998
inside `params`).
@@ -106,6 +115,7 @@ don't, and the only path that supports `session_id` pinning.
106115
| `godot://node/{path}/properties` | All properties of a node by scene path |
107116
| `godot://node/{path}/children` | Direct children (name, type, path each) |
108117
| `godot://node/{path}/groups` | Group memberships for a node |
118+
| `godot://class/{class_name}` | ClassDB metadata: properties, methods, signals, enums, constants, inheritance, and defaults |
109119
| `godot://script/{path}` | GDScript source by res:// path (drop the `res://` prefix) |
110120
| `godot://project/info` | Active project metadata |
111121
| `godot://project/settings` | Common project settings subset |

plugin/addons/godot_ai/dispatcher.gd

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
2121
"game_command": 15000,
2222
}
2323
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
24+
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
2425

2526

2627
func _init(log_buffer: McpLogBuffer) -> void:
@@ -61,18 +62,7 @@ func has_command(command: String) -> bool:
6162
## array if no candidates clear the threshold. Used by batch_execute to surface
6263
## "did you mean" suggestions when an unknown command is passed.
6364
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
64-
if cmd_name.is_empty() or _handlers.is_empty():
65-
return []
66-
var scored: Array = []
67-
for name in _handlers.keys():
68-
var score: float = cmd_name.similarity(name)
69-
if score >= threshold:
70-
scored.append([score, name])
71-
scored.sort_custom(func(a, b): return a[0] > b[0])
72-
var result: Array[String] = []
73-
for i in range(min(limit, scored.size())):
74-
result.append(scored[i][1])
75-
return result
65+
return FuzzySuggestions.rank(cmd_name, _handlers.keys(), limit, threshold, 0.0, 0.0)
7666

7767

7868
## Enqueue a raw command dict received from the WebSocket.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
@tool
2+
extends RefCounted
3+
4+
## Read-only access to version-correct Godot class metadata.
5+
6+
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
7+
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
8+
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
9+
10+
func get_class_info(params: Dictionary) -> Dictionary:
11+
var requested_class: String = params.get("class_name", "")
12+
if requested_class.is_empty():
13+
return ErrorCodes.make(
14+
ErrorCodes.MISSING_REQUIRED_PARAM,
15+
"Missing required param: class_name"
16+
)
17+
if not ClassDB.class_exists(requested_class):
18+
var script_class := _global_script_class(requested_class)
19+
if not script_class.is_empty():
20+
return _script_class_error(requested_class, script_class)
21+
return _unknown_class_error(requested_class)
22+
if params.has("limit") and int(params.get("limit")) < 0:
23+
return ErrorCodes.make(
24+
ErrorCodes.INVALID_PARAMS,
25+
"limit must be >= 0; use limit=0 only when an unlimited section is needed"
26+
)
27+
var section_check := ClassIntrospection.validate_sections(
28+
params.get("sections", ClassIntrospection.DEFAULT_SECTIONS)
29+
)
30+
if not section_check.invalid.is_empty():
31+
return _invalid_sections_error(section_check.invalid)
32+
return {"data": ClassIntrospection.build(requested_class, params)}
33+
34+
35+
static func _unknown_class_error(requested_class: String) -> Dictionary:
36+
var suggestions := _suggest_classes(requested_class)
37+
var message := "Unknown Godot class: %s" % requested_class
38+
if not suggestions.is_empty():
39+
message += ". Did you mean: %s?" % ", ".join(suggestions)
40+
var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message)
41+
result["error"]["data"] = {"suggestions": suggestions}
42+
return result
43+
44+
45+
static func _suggest_classes(requested_class: String) -> Array[String]:
46+
return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list())
47+
48+
49+
static func _global_script_class(requested_class: String) -> Dictionary:
50+
for raw_info in ProjectSettings.get_global_class_list():
51+
var info: Dictionary = raw_info
52+
if info.get("class", "") == requested_class:
53+
return info
54+
return {}
55+
56+
57+
static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary:
58+
var path := str(script_class.get("path", ""))
59+
var base := str(script_class.get("base", ""))
60+
var message := (
61+
"%s is a project script class, not a ClassDB class. "
62+
+ "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols."
63+
) % [requested_class, path]
64+
var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message)
65+
result["error"]["data"] = {
66+
"script_class": true,
67+
"class_name": requested_class,
68+
"base_class": base,
69+
"path": path,
70+
}
71+
return result
72+
73+
74+
static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary:
75+
var suggestions := {}
76+
for section in invalid_sections:
77+
suggestions[section] = FuzzySuggestions.rank(
78+
section,
79+
ClassIntrospection.KNOWN_SECTIONS,
80+
3,
81+
0.3
82+
)
83+
var message := "Unknown class-info section(s): %s. Valid sections: %s" % [
84+
", ".join(invalid_sections),
85+
", ".join(ClassIntrospection.KNOWN_SECTIONS),
86+
]
87+
var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
88+
result["error"]["data"] = {"suggestions": suggestions}
89+
return result
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://v3rkd7ueunii

plugin/addons/godot_ai/handlers/node_handler.gd

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
extends RefCounted
33

44
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
5+
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
56

67
## Handles node creation and manipulation with undo/redo support.
78

@@ -862,67 +863,4 @@ static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> V
862863
## dicts/arrays so agents can inspect fields instead of parsing Godot's
863864
## debug repr — see issue #214.
864865
static func _serialize_value(value: Variant) -> Variant:
865-
if value == null:
866-
return null
867-
match typeof(value):
868-
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
869-
return value
870-
TYPE_STRING_NAME:
871-
return str(value)
872-
TYPE_VECTOR2, TYPE_VECTOR2I:
873-
return {"x": value.x, "y": value.y}
874-
TYPE_VECTOR3, TYPE_VECTOR3I:
875-
return {"x": value.x, "y": value.y, "z": value.z}
876-
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
877-
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
878-
TYPE_COLOR:
879-
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
880-
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
881-
return {
882-
"position": _serialize_value(value.position),
883-
"size": _serialize_value(value.size),
884-
}
885-
TYPE_PLANE:
886-
return {"normal": _serialize_value(value.normal), "d": value.d}
887-
TYPE_BASIS:
888-
return {
889-
"x": _serialize_value(value.x),
890-
"y": _serialize_value(value.y),
891-
"z": _serialize_value(value.z),
892-
}
893-
TYPE_TRANSFORM2D:
894-
return {
895-
"x": _serialize_value(value.x),
896-
"y": _serialize_value(value.y),
897-
"origin": _serialize_value(value.origin),
898-
}
899-
TYPE_TRANSFORM3D:
900-
return {
901-
"basis": _serialize_value(value.basis),
902-
"origin": _serialize_value(value.origin),
903-
}
904-
TYPE_PROJECTION:
905-
return {
906-
"x": _serialize_value(value.x),
907-
"y": _serialize_value(value.y),
908-
"z": _serialize_value(value.z),
909-
"w": _serialize_value(value.w),
910-
}
911-
TYPE_NODE_PATH:
912-
return str(value)
913-
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY:
914-
var arr: Array = []
915-
for item in value:
916-
arr.append(_serialize_value(item))
917-
return arr
918-
TYPE_DICTIONARY:
919-
var out := {}
920-
for k in value:
921-
out[str(k)] = _serialize_value(value[k])
922-
return out
923-
TYPE_OBJECT:
924-
if value is Resource and value.resource_path:
925-
return value.resource_path
926-
return str(value)
927-
_:
928-
return str(value)
866+
return VariantSerializer.serialize(value)

plugin/addons/godot_ai/handlers/resource_handler.gd

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
extends RefCounted
33

44
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
5+
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
56

67
## Handles resource search, inspection, and assignment to nodes.
78

@@ -373,37 +374,25 @@ func get_resource_info(params: Dictionary) -> Dictionary:
373374
"%s is not a Resource type (extends %s)" % [type_str, parent]
374375
)
375376

376-
var properties: Array[Dictionary] = []
377-
for prop in ClassDB.class_get_property_list(type_str):
378-
var usage: int = prop.get("usage", 0)
379-
if not (usage & PROPERTY_USAGE_EDITOR):
380-
continue
381-
properties.append({
382-
"name": prop.name,
383-
"type": type_string(prop.type),
384-
"hint": prop.get("hint", 0),
385-
"usage": usage,
386-
})
387-
properties.sort_custom(func(a, b): return a.name < b.name)
388-
389377
var can_instantiate: bool = ClassDB.can_instantiate(type_str)
378+
var class_info := ClassIntrospection.build(type_str, {
379+
"sections": ["properties"],
380+
"include_inherited": true,
381+
"include_inheritors": not can_instantiate,
382+
"limit": 0,
383+
})
390384
var data: Dictionary = {
391385
"type": type_str,
392-
"parent_class": ClassDB.get_parent_class(type_str),
386+
"parent_class": class_info.parent_class,
393387
"can_instantiate": can_instantiate,
394388
"is_abstract": not can_instantiate,
395-
"properties": properties,
396-
"property_count": properties.size(),
389+
"properties": class_info.properties,
390+
"property_count": class_info.property_count,
397391
}
398392

399393
# For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface
400394
# the concrete Resource subclasses an agent could try next.
401395
if not can_instantiate:
402-
var subclasses: Array[String] = []
403-
for cls in ClassDB.get_inheriters_from_class(type_str):
404-
if ClassDB.can_instantiate(cls) and ClassDB.is_parent_class(cls, "Resource"):
405-
subclasses.append(cls)
406-
subclasses.sort()
407-
data["concrete_subclasses"] = subclasses
396+
data["concrete_subclasses"] = class_info.concrete_inheritors
408397

409398
return {"data": data}

plugin/addons/godot_ai/plugin.gd

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const ProjectHandler := preload("res://addons/godot_ai/handlers/project_handler.
6464
const ClientHandler := preload("res://addons/godot_ai/handlers/client_handler.gd")
6565
const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
6666
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
67+
const ApiHandler := preload("res://addons/godot_ai/handlers/api_handler.gd")
6768
const FilesystemHandler := preload("res://addons/godot_ai/handlers/filesystem_handler.gd")
6869
const SignalHandler := preload("res://addons/godot_ai/handlers/signal_handler.gd")
6970
const AutoloadHandler := preload("res://addons/godot_ai/handlers/autoload_handler.gd")
@@ -243,6 +244,7 @@ func _enter_tree() -> void:
243244
var client_handler := ClientHandler.new()
244245
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
245246
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
247+
var api_handler := ApiHandler.new()
246248
var filesystem_handler := FilesystemHandler.new()
247249
var signal_handler := SignalHandler.new(get_undo_redo())
248250
var autoload_handler := AutoloadHandler.new()
@@ -261,7 +263,7 @@ func _enter_tree() -> void:
261263
var texture_handler := TextureHandler.new(get_undo_redo(), _connection)
262264
var curve_handler := CurveHandler.new(get_undo_redo(), _connection)
263265
var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo())
264-
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
266+
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
265267

266268
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
267269
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
@@ -312,6 +314,7 @@ func _enter_tree() -> void:
312314
_dispatcher.register("assign_resource", resource_handler.assign_resource)
313315
_dispatcher.register("create_resource", resource_handler.create_resource)
314316
_dispatcher.register("get_resource_info", resource_handler.get_resource_info)
317+
_dispatcher.register("get_class_info", api_handler.get_class_info)
315318
_dispatcher.register("read_file", filesystem_handler.read_file)
316319
_dispatcher.register("write_file", filesystem_handler.write_file)
317320
_dispatcher.register("reimport", filesystem_handler.reimport)

plugin/addons/godot_ai/tool_catalog.gd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const CORE_TOOLS := [
3030
## tools: flat list of tool names registered by this domain (non-core only)
3131
const DOMAINS := [
3232
{"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]},
33+
{"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]},
3334
{"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]},
3435
{"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]},
3536
{"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]},

0 commit comments

Comments
 (0)