Skip to main content
Glama
Eomsangsik

sangsik-gdscript-graph

by Eomsangsik

sangsik-gdscript-graph

A function-level call-graph indexer for GDScript/Godot projects, exposed as an MCP server so AI coding assistants can answer questions like "what calls this function?" or "what would break if I change this signal?" without re-reading the whole codebase.

It parses every .gd file in a Godot project with gdtoolkit's GDScript grammar, resolves calls and signal connections across files (autoloads, class_name, inheritance, inner classes, typed fields, lambdas, property accessors, chained signal receivers, etc.), reads the signal connections saved in .tscn scenes, and stores the result in a SQLite database that the MCP server queries on demand.

Features

  • Call resolution across bare calls, self. calls, super. calls, autoloads, class_names, preload consts, and typed receivers — locals, parameters, and member vars (including inherited ones), typed by annotation (var menu: PauseMenu) or by a := whose type is certain (:= $Menu as PauseMenu, := PauseMenu.new(), or := make_menu() / := repo.find() through the called function's -> T return type), plus calls on a call's result (Repo.open().find()), (expr as T).f() casts and field chains like self.hud.menu.open() — all following inheritance chains. T.new(...) and super(...) link to the _init they run.

  • Engine calls told apart from missed ones: using the Godot 4.6 API (bundled), a call that doesn't resolve to project code is classified — a GDScript built-in (int(), print(), Vector2()), an engine method inherited by the caller (queue_free()), or a call on an engine-typed value (some_dict.has(), get_tree().create_timer()) — so status's unresolved_calls counts only calls the graph may really have missed.

  • Signal handlers are never "dead code": a handler connected in the Godot editor (a [connection] saved in a .tscn — including through instanced, inherited, and editable-children scenes) or with .connect(...) in code (to a project signal or an engine one like $Button.pressed) shows up in callers and impact, so an assistant doesn't mistake it for unused code. Project-declared signals are linked on both sides (GameManager.card_drawn.connect(handler), signal_handlers).

  • Scoping: inner classes (with their own extends, and extends Outer.Inner), property accessors (get:/set(value):), lambdas, static functions are all tracked with correct scope boundaries.

  • Robust by design: pathologically deep GDScript files are isolated (a parse/recursion failure in one file doesn't abort the whole build), builds are atomic (a failed rebuild never corrupts an existing database) and never overlap — not within one server, nor across servers or a CLI build sharing a database — and known ReDoS-prone .tscn parsing paths are hardened. A string with a line break in it (valid Godot, rejected by gdtoolkit's grammar) no longer drops its file, and a file the parser still rejects keeps its class_name/extends/functions/signals from a line scan.

  • Sees the project the way Godot does: hidden directories (.godot, .git, ...) and ones with a .gdignore are skipped, and a symlinked addon is indexed under its res://addons/... path.

  • Transitive impact analysis: BFS over the call graph to answer "everything that calls (or is called by) this function, up to N hops."

  • Auto-sync while the server runs: an OS-level file watcher (FSEvents/inotify/ReadDirectoryChangesW) rebuilds the graph automatically a short debounce window after you edit a .gd/.tscn/project.godot file — no manual rebuild needed during a normal editing session.

  • Incremental rebuilds: each build caches every file's extraction (symbols, calls, local types — keyed by content hash); a rebuild re-extracts only what changed and re-resolves everything, with output identical to a full rebuild either way. A file whose modification time and size are unchanged isn't even re-read. On a 2,127-file project a rebuild with nothing changed takes ~3 s (down from ~19 s with the previous parse-tree cache), peak memory ~200 MB (from ~1.1 GB), and the database is 23 MB (from 132 MB).

  • Reconciles offline edits on startup: a live file watcher only sees changes made after it starts, so every server start checks whether any .gd/.tscn/project.godot file changed (modification time and size) since the database was built — ~1.3 s on the same project — and rebuilds in the background only if one did.

  • One rebuilding server per database: several MCP servers on one database (one per editor session) elect a single leader that watches and rebuilds; the others just serve queries and take over if it exits.

  • Bounded responses: list results come one page at a time (50 by default), grouped by file, with total/next_offset — a helper with 2,000+ call sites no longer overflows an MCP client's response limit. Any result produced while a rebuild is pending says stale: true.

Related MCP server: godot-devtool

Performance

benchmarks/ has a reproducible benchmark comparing the MCP tools against grep-based exploration, run against a small purpose-built fixture project (not a real game — a synthetic one deliberately shaped to include both the strong and weak cases for this tool, e.g. a fact it can't see at all). Reproduce it yourself:

pip install -e . tiktoken
.venv/bin/python benchmarks/generate_fixture.py
.venv/bin/python -m gdscript_graph.cli build benchmarks/fixture_project -o benchmarks/fixture_project.db
.venv/bin/python benchmarks/compare_mcp_vs_grep.py

Methodology: MCP-side numbers are the actual bytes returned by a real MCP stdio session calling the real tools (what an agent's context would actually receive). grep-side numbers use the real grep binary in three postures: raw output only, output plus a few lines of context read around each match (disciplined), and output plus the whole containing file read for each match (a common, less disciplined agent default). Token counts use tiktoken's cl100k_base encoding — not Claude's exact tokenizer (not public), but a reproducible, order-of-magnitude-accurate stand-in.

Where the savings actually come from

The MCP response payload isn't reliably smaller than grep's raw output for a full listing — callers pages large results (50 call sites per call, grouped by file), which keeps one response bounded but means the log_debug row below is the first page, not everything. The real savings come from three separate mechanisms: avoiding full-file reads (largest effect — a file:line result needs 10-20 targeted lines instead of a whole file), fewer round trips (a multi-hop question resolves in one call instead of repeated grep→read→re-grep cycles), and precision (no false positives from comments, strings, or similarly-named symbols).

Measured results

Scenario

Task type

MCP tokens

grep-only

grep + minimal read

grep + full-file read

calculate_damage callers

Moderately common function

190

509

1,152

2,911

save_game impact (2-hop facade chain)

Change-impact / call-chain tracing

106

342

620

5,624

apply_critical_hit_multiplier_v2 callers

Rare, uniquely-named symbol

55

79

190

636

PlayerStats type usages

Type/field usage site

finds nothing (22: an empty result)

199

374

1,822

log_debug callers

Ultra-common generic helper

1,087 (first 50 of 129)

5,016

10,450

8,850

Raw query latency (9-run average, backend DB query vs. grep -rn, same fixture): ~0.05ms vs. ~5.2ms (~100x) — though in practice, round-trip count and tokens processed dominate an agent's perceived speed far more than raw query time.

Reading this honestly:

  • The facade-chain scenario is the biggest win (5.8x vs. a disciplined grep, up to 53x vs. a grep-and-read-the-whole-file agent) — grep genuinely cannot see through one manager delegating to another without reading the call site's body.

  • The rare-symbol case still wins here (3.5x), but it's the smallest margin among the winning scenarios — the more disciplined the grep-based approach is, the more this gap narrows. (The response envelope — total, next_offset, grouping — costs a few tokens on a tiny result.)

  • The type-usage scenario is a real, unambiguous loss: callers("PlayerStats") returns zero results, because the call graph only tracks calls and signal connections, not type annotations. grep is the only thing that works for "where is this class used as a type?"

  • The ultra-common-helper row isn't like-for-like: the 1,087 tokens are the first page (50 of 129 call sites, grouped by file). Paging through all 129 costs 2,897 tokens — a bit over half of raw grep output, since each file path is sent once per page rather than once per call site.

Honest limitations

  • Type/field usage sites are invisible to the call graph — confirmed above, not just a hypothetical. grep is strictly better for "where is this type referenced?"

  • Reading and modifying a function's own body costs the same either way — these tools only cut cost at the locate-and-trace stage.

  • Every match of an extremely common name takes several pages — narrowing with file/scope, or asking a more specific question, matters once a name has hundreds of call sites.

  • The index depends on freshness — a change needs a rebuild to show up. The file watcher (see Features) makes this automatic, but there's still the debounce window's worth of latency before an edit is reflected.

One fixture project, one moment in time — treat these as illustrative of the mechanism (and reproducible, unlike a one-off number), not a guaranteed multiplier for any given codebase.

Installation

pip install -e .

Requires Python 3.10+.

Usage

1. Build the graph

gdscript-graph build /path/to/godot/project -o graph.db

This scans every .gd file (plus project.godot for autoloads, and every .tscn scene for editor-made signal connections and autoload scenes), resolves calls and signal connections, and writes a SQLite database to graph.db (defaults to <project>/.gdscript_graph.db if -o is omitted). A database built by an older version is rejected by the server with a message to rebuild it.

2. Run the MCP server

gdscript-graph mcp graph.db

This starts an MCP server over stdio and, by default, watches the project directory (recorded in the database at build time) for changes -- editing a .gd/.tscn/project.godot file triggers an automatic rebuild ~2 seconds after your last edit, with no restart needed. Pass --no-watch to disable this, or --debounce-ms <n> to change the delay.

Point your MCP client (Claude Code, Claude Desktop, etc.) at this command, e.g. in a Claude Code MCP config:

{
  "mcpServers": {
    "gdscript-graph": {
      "command": "gdscript-graph",
      "args": ["mcp", "/path/to/graph.db"]
    }
  }
}

Available MCP tools

Tool

Description

search(query, limit=20, kind=None, path=None)

Substring search over functions, signals, vars, consts, and enums — exact matches first, then prefix matches; kind/path narrow it.

status()

Index health/freshness: file/symbol/call/signal/scene-connection counts (unresolved calls split from calls into the engine), last build time, whether a rebuild is pending or in flight, and this server's watch_role.

node(name, file=None, scope=None, kind=None, callers_limit=20)

A symbol's verbatim source (re-read fresh from disk) plus its first callers/handlers, in one call.

explore(names, max_path_depth=6, callers_limit=10)

node's detail for several symbols at once, plus the actual call path connecting each resolved pair, if one exists.

files(prefix=None, limit=50, offset=0)

List indexed files with class_name/extends/parse_error and a symbol-count breakdown; prefix narrows to a subdirectory or file, and a partial listing counts files per subdirectory.

callers(function_name, file=None, scope=None, limit=50, offset=0)

List call sites of the given function, plus its signal-handler registrations (via: "connect" for .connect() in code, via: "scene" for a .tscn connection).

callees(function_name, file=None, scope=None, limit=50, offset=0)

List functions called from within the given function, plus handlers it .connect()s (via: "connect").

signal_handlers(signal_name, file=None, scope=None, limit=50, offset=0)

List handlers connected to a signal, via .connect(...) or in a .tscn scene.

impact(function_name, file=None, scope=None, direction="callers", max_depth=5, limit=50, offset=0)

Transitively walk the call graph (signal registrations included) to find everything affected by changing a function, nearest first.

file/scope disambiguate when multiple declarations share a name (same-named function in different files or inner classes). callers/callees merge the results of every matching declaration, marking which one each entry belongs to; impact instead lists the candidates when the name is declared in more than one file. List results are {total, next_offset, by_file} pages — pass offset=next_offset for the next one.

What it can't see

Resolution is static and deliberately conservative — a call it can't type stays unresolved (and is recorded as such) rather than guessed, so a missing edge is possible but a wrong one shouldn't be:

  • Untyped variables: var x = PauseMenu.new() (no annotation, no :=) is Variant and can be reassigned, so calls through it aren't resolved. Neither are calls through a function with no -> T return type, $Node / get_node() without an as cast or typed variable, or an array element (items[0].use()).

  • Engine types and dynamic dispatch: calls on engine classes (Timer, Node...) aren't project code (they're counted as engine_calls); a call through a base-class-typed variable resolves to the base's method, not to subclass overrides.

  • Scenes: built-in (embedded) scripts and C# scripts aren't indexed, and binary .scn scenes aren't read. Godot 3's string-based connect("signal", target, "method") isn't tracked.

  • Type usages: callers("SomeClass") doesn't list where a class is used as a type — grep is better for that (see Performance).

Development

pip install -e . pytest
pytest -q

CI runs the suite on Linux and macOS (Python 3.10 and 3.13), plus once against the oldest supported dependency versions. See CHANGELOG.md for release notes.

The MCP server re-reads the DB file fresh on every query, so any rebuild while the server is running (whether triggered by the file watcher or run manually) is picked up immediately without a restart.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Provides instant GDScript syntax validation and diagnostics by bridging Godot's native Language Server Protocol to MCP clients. Enables real-time syntax checking in AI assistants without requiring custom plugins or context switching to the Godot editor.
    4
    51 npm
    47
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    An MCP server that indexes your codebase using tree-sitter AST parsing and gives AI tools instant access to structural intelligence like dependency graphs, call trees, and dead code detection from a local SQLite database.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Offline static correctness MCP server for Godot 4.x GDScript projects that lints code, looks up API symbols, and validates scenes/resources, all without a running editor.
    4 npm
    Creative Commons Attribution Non Commercial No Derivatives 4.0 International