@tool extends RefCounted ## classdb.* command handlers — ClassDB and global class introspection. const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd") const _VALID_SECTIONS: Array[String] = ["properties", "methods", "signals", "constants"] const _MAX_ENTRIES_PER_SECTION := 200 const _MAX_SEARCH_RESULTS := 200 static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void: registry.add("classdb.get_info", func(parameters: Dictionary) -> Dictionary: return _cmd_classdb_get_info(parameters) , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) registry.add("classdb.search", func(parameters: Dictionary) -> Dictionary: return _cmd_classdb_search(parameters) , MCPToolkitCommandOptions.new().mark_read_only().mark_scene_independent()) # -- Command ----------------------------------------------------------------- ## Resolves the optional caller `limit` for a classdb page against [param hard_max]. ## A missing limit defaults to [param hard_max] (behavior-preserving); a value above it ## clamps to [param hard_max] and flags the clamp; a non-positive value is rejected. ## Returns {"limit": , "clamped": , "error": null} on success, or ## {"error": } when the limit is out of range. static func _resolve_limit(parameters: Dictionary, hard_max: int) -> Dictionary: if not parameters.has("limit"): return {"limit": hard_max, "clamped": false, "error": null} var requested := int(parameters.get("limit", hard_max)) if requested < 1: return {"error": MCPToolkitError.fail("INVALID_PARAMS", "limit must be >= 1 (got %d)" % requested)} return { "limit": mini(requested, hard_max), "clamped": requested > hard_max, "error": null, } static func _cmd_classdb_get_info(parameters: Dictionary) -> Dictionary: var cls: String = str(parameters.get("class_name", "")) if cls == "": return MCPToolkitError.fail("INVALID_PARAMS", "class_name is required") var include_inherited: bool = bool(parameters.get("include_inherited", false)) var offset: int = maxi(int(parameters.get("offset", 0)), 0) var limit_res := _resolve_limit(parameters, _MAX_ENTRIES_PER_SECTION) if limit_res["error"] != null: return limit_res["error"] var section_cap: int = limit_res["limit"] var sections: Array = parameters.get("sections", []) if typeof(sections) != TYPE_ARRAY: sections = [] for s in sections: if str(s) not in _VALID_SECTIONS: return MCPToolkitError.fail("INVALID_PARAMS", "invalid section '%s'; valid: %s" % [s, ", ".join(_VALID_SECTIONS)]) var want_all := sections.is_empty() var want_properties := want_all or "properties" in sections var want_methods := want_all or "methods" in sections var want_signals := want_all or "signals" in sections var want_constants := want_all or "constants" in sections # Two-stage resolution: ClassDB (native engine classes) first, # then global class list (user-defined class_name classes). var is_native := ClassDB.class_exists(cls) var is_global := false var global_entry: Dictionary = {} var script_path := "" if not is_native: for entry in ProjectSettings.get_global_class_list(): if entry.get("class", "") == cls: is_global = true global_entry = entry script_path = str(entry.get("path", "")) break if not is_native and not is_global: return MCPToolkitError.fail("UNKNOWN_CLASS", "class not found in ClassDB or global class list: %s" % cls, MCPToolkitError.HINT_CLASS_NAME) var result: Dictionary = {"class_name": cls} var has_more := false if is_native: result["source"] = "native" result["parent"] = ClassDB.get_parent_class(cls) result["inheritance_chain"] = _build_chain(cls) var no_inheritance := not include_inherited if want_properties: has_more = _add_property_section(result, ClassDB.class_get_property_list(cls, no_inheritance), offset, section_cap) or has_more if want_methods: has_more = _add_method_section(result, ClassDB.class_get_method_list(cls, no_inheritance), offset, section_cap) or has_more if want_signals: has_more = _add_signal_section(result, ClassDB.class_get_signal_list(cls, no_inheritance), offset, section_cap) or has_more if want_constants: has_more = _add_constants_native(result, cls, no_inheritance, offset, section_cap) or has_more else: result["source"] = "global" result["script_path"] = script_path result["parent"] = str(global_entry.get("base", "")) result["inheritance_chain"] = _build_chain_global(cls, global_entry) var script := ResourceLoader.load(script_path) as Script if script == null: return MCPToolkitError.fail("LOAD_FAILED", "could not load script at %s for class %s" % [script_path, cls]) if want_properties: has_more = _add_property_section(result, script.get_script_property_list(), offset, section_cap) or has_more if want_methods: has_more = _add_method_section(result, script.get_script_method_list(), offset, section_cap) or has_more if want_signals: has_more = _add_signal_section(result, script.get_script_signal_list(), offset, section_cap) or has_more if want_constants: result["constants"] = {} result["total_constants"] = 0 result["enums"] = {} result["total_enums"] = 0 # has_more is ALWAYS present (false on a complete read). On a single-section # overflow the hint builder also adds a structured next_offset resume field. result["has_more"] = has_more if limit_res["clamped"]: result["limit_clamped"] = true if has_more: var hint := _build_truncation_hint(result, offset) if hint != "": result["hint"] = hint return MCPToolkitSuccess.ok(result) static func _cmd_classdb_search(parameters: Dictionary) -> Dictionary: var base_class: String = str(parameters.get("base_class", "")) var pattern: String = str(parameters.get("pattern", "")) var instantiable_only: bool = bool(parameters.get("instantiable_only", true)) var include_global: bool = bool(parameters.get("include_global", true)) var offset: int = maxi(int(parameters.get("offset", 0)), 0) var limit_res := _resolve_limit(parameters, _MAX_SEARCH_RESULTS) if limit_res["error"] != null: return limit_res["error"] var page_cap: int = limit_res["limit"] if base_class != "": var base_exists := ClassDB.class_exists(base_class) if not base_exists: for entry in ProjectSettings.get_global_class_list(): if entry.get("class", "") == base_class: base_exists = true break if not base_exists: return MCPToolkitError.fail("UNKNOWN_CLASS", "base_class not found in ClassDB or global class list: %s" % base_class, MCPToolkitError.HINT_CLASS_NAME) var pattern_lower := pattern.to_lower() var matches: Array = [] var total := 0 # Native classes from ClassDB. for cls in ClassDB.get_class_list(): if base_class != "" and cls != base_class \ and not ClassDB.is_parent_class(cls, base_class): continue if pattern_lower != "" and cls.to_lower().find(pattern_lower) == -1: continue if instantiable_only and not ClassDB.can_instantiate(cls): continue total += 1 if total > offset and matches.size() < page_cap: matches.append({ "name": cls, "parent": ClassDB.get_parent_class(cls), "instantiable": ClassDB.can_instantiate(cls), "source": "native", }) # Global (user-defined class_name) classes. if include_global: for entry in ProjectSettings.get_global_class_list(): var cls: String = str(entry.get("class", "")) var base: String = str(entry.get("base", "")) if cls == "": continue if base_class != "": if cls != base_class and base != base_class: var ancestor_match := false var current := base while current != "": if current == base_class: ancestor_match = true break if ClassDB.class_exists(current): current = ClassDB.get_parent_class(current) else: var found := false for g in ProjectSettings.get_global_class_list(): if g.get("class", "") == current: current = str(g.get("base", "")) found = true break if not found: break if not ancestor_match: continue if pattern_lower != "" and cls.to_lower().find(pattern_lower) == -1: continue total += 1 if total > offset and matches.size() < page_cap: matches.append({ "name": cls, "parent": base, "instantiable": true, "source": "global", "script_path": str(entry.get("path", "")), }) # If neither filter was given, return only direct children of Object # to avoid dumping 1000+ classes. if base_class == "" and pattern == "": var top_level: Array = [] for m in matches: if m["parent"] == "Object" or m["parent"] == "": top_level.append(m) top_level.sort_custom(func(a, b): return str(a["name"]) < str(b["name"])) var top_page := Modules.Pagination.build( {"classes": top_level}, "classes", top_level.size(), top_level.size(), false, "", 0, "") # Informational nudge — shown regardless of paging, so it is set after the # envelope rather than through the builder's has_more-gated hint. top_page["hint"] = "No filter provided; showing direct children of Object only. Use base_class or pattern to search." return MCPToolkitSuccess.ok(top_page) matches.sort_custom(func(a, b): return str(a["name"]) < str(b["name"])) # Canonical fields: total_classes + has_more ALWAYS present; on more, the builder # adds the next_offset resume field beside the prose hint composed here. var has_more := offset + matches.size() < total var hint := "" if has_more: hint = "more classes remain — re-call classdb.search with offset = next_offset (%d) until has_more is false" % (offset + matches.size()) var extras: Dictionary = {} if limit_res["clamped"]: extras["limit_clamped"] = true var clamp_clause := "requested limit exceeds max %d — page with next_offset to see the rest" % _MAX_SEARCH_RESULTS hint = clamp_clause if hint.is_empty() else "%s. %s" % [hint, clamp_clause] return MCPToolkitSuccess.ok(Modules.Pagination.list_page( {"classes": matches}, matches, offset, "classes", total, hint, extras)) # -- Inheritance chain ------------------------------------------------------- static func _build_chain(cls: String) -> Array[String]: var chain: Array[String] = [] var current := cls while current != "": chain.append(current) current = ClassDB.get_parent_class(current) return chain static func _build_chain_global(cls: String, entry: Dictionary) -> Array[String]: var chain: Array[String] = [cls] var current: String = str(entry.get("base", "")) while current != "": chain.append(current) if ClassDB.class_exists(current): current = ClassDB.get_parent_class(current) else: var found := false for g in ProjectSettings.get_global_class_list(): if g.get("class", "") == current: current = str(g.get("base", "")) found = true break if not found: break return chain # -- Section builders -------------------------------------------------------- # Each builder paginates a pre-fetched metadata list (`raw`) and writes its # section + "total_
" into `result`. The caller supplies `raw` from # whichever source applies — ClassDB.class_get_*_list() for native classes, # script.get_script_*_list() for global (class_name) classes — so the builders # depend only on the rows, not on how they were fetched. static func _add_property_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: var total := raw.size() result["total_properties"] = total var out: Array = [] var skipped := 0 for p in raw: if skipped < offset: skipped += 1 continue if out.size() >= cap: break out.append({ "name": p.get("name", ""), "type": p.get("type", 0), "hint": p.get("hint", 0), "hint_string": p.get("hint_string", ""), }) result["properties"] = out return offset + out.size() < total static func _add_method_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: var total := raw.size() result["total_methods"] = total var out: Array = [] var skipped := 0 for m in raw: if skipped < offset: skipped += 1 continue if out.size() >= cap: break out.append({ "name": m.get("name", ""), "args": _format_args(m.get("args", [])), "return_type": _resolve_type(m.get("return", {}), true), "flags": m.get("flags", 0), }) result["methods"] = out return offset + out.size() < total static func _add_signal_section(result: Dictionary, raw: Array, offset: int, cap: int) -> bool: var total := raw.size() result["total_signals"] = total var out: Array = [] var skipped := 0 for s in raw: if skipped < offset: skipped += 1 continue if out.size() >= cap: break out.append({ "name": s.get("name", ""), "args": _format_args(s.get("args", [])), }) result["signals"] = out return offset + out.size() < total # -- Native-only sections (constants + enums; no script twin) ----------------- static func _add_constants_native( result: Dictionary, cls: String, no_inheritance: bool, offset: int, cap: int, ) -> bool: var const_list := ClassDB.class_get_integer_constant_list(cls, no_inheritance) var const_total := const_list.size() result["total_constants"] = const_total var constants: Dictionary = {} var skipped := 0 for c_name in const_list: if skipped < offset: skipped += 1 continue if constants.size() >= cap: break constants[c_name] = ClassDB.class_get_integer_constant(cls, c_name) result["constants"] = constants var enum_list := ClassDB.class_get_enum_list(cls, no_inheritance) var enum_total := enum_list.size() result["total_enums"] = enum_total var enums: Dictionary = {} var enum_skipped := 0 for enum_name in enum_list: if enum_skipped < offset: enum_skipped += 1 continue if enums.size() >= cap: break var members: Array[String] = [] for ec in ClassDB.class_get_enum_constants(cls, enum_name, no_inheritance): members.append(ec) enums[enum_name] = members result["enums"] = enums return offset + constants.size() < const_total or offset + enums.size() < enum_total # -- Truncation hint builder -------------------------------------------------- static func _build_truncation_hint(result: Dictionary, offset: int) -> String: var parts: Array[String] = [] var single_section := "" var single_next_offset := 0 for section in ["properties", "methods", "signals", "constants", "enums"]: var total_key: String = "total_" + section if not result.has(total_key): continue var total: int = result[total_key] var returned := 0 if result.has(section): var val = result[section] if val is Array: returned = val.size() elif val is Dictionary: returned = val.size() var remaining := total - offset - returned if remaining > 0: parts.append("%d %s" % [remaining, section]) single_section = section single_next_offset = offset + returned if parts.is_empty(): return "" if parts.size() == 1: # Surface the resume field as a structured next_offset alongside the prose loop hint. result["next_offset"] = single_next_offset return "more %s remain — re-call classdb.get_info with offset = next_offset (%d) until has_more is false" % [ single_section, single_next_offset] return "%s has more entries — page each section with sections + offset until has_more is false." % " + ".join(parts) # -- Shared formatting helpers ----------------------------------------------- static func _format_args(args: Array) -> Array: var out: Array = [] for a in args: out.append({ "name": a.get("name", ""), "type": _resolve_type(a, false), }) return out static func _resolve_type(info: Variant, is_return: bool) -> String: if typeof(info) != TYPE_DICTIONARY: return "void" if is_return else "Variant" var type_id: int = info.get("type", 0) if type_id == TYPE_OBJECT: var cls_name: String = str(info.get("class_name", "")) if cls_name != "": return cls_name return "Object" if type_id == TYPE_NIL: return "void" if is_return else "Variant" return type_string(type_id)