# mcp-gtags-server > **Stop letting your AI agent grep. Give it an index.** [![PyPI](https://img.shields.io/pypi/v/mcp-gtags-server)](https://pypi.org/project/mcp-gtags-server/) [![CI](https://github.com/harshithsunku/mcp-gtags-server/actions/workflows/ci.yml/badge.svg)](https://github.com/harshithsunku/mcp-gtags-server/actions/workflows/ci.yml) [![Kernel eval](https://github.com/harshithsunku/mcp-gtags-server/actions/workflows/eval.yml/badge.svg)](https://github.com/harshithsunku/mcp-gtags-server/actions/workflows/eval.yml) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Docs](https://img.shields.io/badge/docs-harshithsunku.github.io-blue)](https://harshithsunku.github.io/mcp-gtags-server/) [![MCP](https://img.shields.io/badge/protocol-MCP-8A2BE2)](https://modelcontextprotocol.io/) [![Powered by GNU Global](https://img.shields.io/badge/powered%20by-GNU%20Global-orange)](https://www.gnu.org/software/global/) [![Install in Cursor](https://img.shields.io/badge/Cursor-one--click%20install-black)](https://cursor.com/en/install-mcp?name=gtags&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtY3AtZ3RhZ3Mtc2VydmVyIl19) mcp-name: io.github.harshithsunku/mcp-gtags-server ```bash # If you don't have uv yet (one-time, no sudo): curl -LsSf https://astral.sh/uv/install.sh | sh ``` ```json { "mcpServers": { "gtags": { "command": "uvx", "args": ["mcp-gtags-server"] } } } ``` *One user-level config entry, no sudo, no pre-installed anything โ€” the whole toolchain installs itself into user space on first use, and every repo you open is served automatically.* ๐Ÿ“– **[Full documentation](https://harshithsunku.github.io/mcp-gtags-server/)** โ€” install for every client, the tool reference, output format, kernel features, configuration and troubleshooting. Every AI coding agent โ€” Claude Code, Cursor, Codex, you name it โ€” answers *"where is this function defined?"* the same way: **grep the entire tree**. On a million-line C/C++ codebase that's a full scan per question, and the output is a firehose: every comment, string literal, and unrelated match, dumped straight into the model's context window. **mcp-gtags-server** replaces those scans with indexed lookups powered by [GNU Global (gtags)](https://www.gnu.org/software/global/) โ€” the same tags engine kernel and systems developers have trusted for decades โ€” exposed to agents over the [Model Context Protocol](https://modelcontextprotocol.io/). Built for the codebases LSP-based tools can't handle: kernel-scale C/C++, trees that don't currently compile, machines you can't sudo on. - **~100ร— faster per query** โ€” milliseconds instead of seconds, at any codebase size - **Radically less noise** โ€” the definition, not 7,873 lines of matches - **Speaks kernel** โ€” `#ifdef` guard stacks with `.config` filtering, macro-generated symbols (`sys_read` โ†’ its `SYSCALL_DEFINE3` site) that no other tagging tool resolves, and definitions the parser misses recovered from their `EXPORT_SYMBOL` site via ctags - **Zero index management** โ€” first query builds the index, every query auto-refreshes it - **Correctness measured in CI** โ€” a 64-case golden eval covering all 8 tools against a pinned kernel: 100% recall, 100% precision@1 ([docs/capability.md](docs/capability.md)) - **Works everywhere MCP does** โ€” Claude Code, Claude Desktop, Cursor, any MCP client ## The numbers (real Linux kernel, not a toy) Measured on a full Linux kernel checkout โ€” **65,163 C/C++ files, 37.1 million lines** โ€” warm page cache: | Question an agent asks | `grep -rn` | gtags (this server) | Context consumed | |---|---|---|---| | Where is `tcp_v4_rcv` defined? | 1.40 s | **0.01 s** | 8 lines โ†’ **1 line** | | Where is `kmalloc` defined? | 1.62 s | **0.01 s** | 7,873 lines โ†’ **5 lines** | | Who references `kmalloc`? | 1.62 s | **0.10 s** | 7,873 noisy lines โ†’ 2,744 real sites (or a **ranked per-file summary**) | | Show me `tcp_v4_rcv`'s implementation | *read a 3,500-line file* | **`get_symbol_body`** | **exactly the 271-line function** | | Who calls `ext4_mark_inode_dirty`? | 245 raw match lines | **`find_callers`** | **62 deduped caller functions, with counts** | | Where is `sys_read` *really* defined? | *no answer โ€” the name is macro-generated* | **0.03 s** | `fs/read_write.c SYSCALL_DEFINE3(read, ...)`, flagged `resolved_via` | | Where is `mutex_lock` defined? | 24,774 noisy match lines | **0.2 s** | `kernel/locking/mutex.c:314` โ€” recovered via `EXPORT_SYMBOL` + ctags after gtags' parser derails on it | | Does `ksys_read` ever reach `rw_verify_area`? | *N rounds of grep + reading* | **0.6 s** | the shortest call chain, with every call site's file:line | | Every `mutex_lock` call in `fs/ext4`? | 22,905 tree-wide match lines | **0.01 s** | `find_references(path_prefix="fs/ext4")`: the 18 real sites | One-time index build: **66 s** for the whole kernel. Incremental refresh after edits: well under a second. Reproduce it yourself with [`scripts/benchmark.sh`](scripts/benchmark.sh): ```bash ./scripts/benchmark.sh /path/to/linux tcp_v4_rcv kmalloc ext4_readdir ``` The speed is nice. The real win is **precision**: an agent that gets 5 exact lines instead of 7,873 noisy ones keeps its context window for actual reasoning. And the answers are *measured*, not assumed: CI runs a [64-case golden eval](evals/golden.jsonl) against pinned kernel v6.16 on every push โ€” currently **100% recall, 100% precision@1** across definitions, macro resolution, export recovery, references, callers, callees, definition bodies, `#ifdef` guards, and reachability, covering all 8 tools. The full methodology, numbers, and honest limitations live in [docs/capability.md](docs/capability.md). ## Quick start (60 seconds) ### Option A โ€” one config entry (recommended) Add the server **once, at user level**, and you're done โ€” no installer, no pre-installed gtags, no per-repo setup. The only prerequisite is [`uv`](https://docs.astral.sh/uv/) (or use Option B, which installs it for you). ```bash # Claude Code (once per device, all repos): claude mcp add --scope user gtags -- uvx mcp-gtags-server ``` ```json // Cursor (~/.cursor/mcp.json), or any MCP client's global settings: { "mcpServers": { "gtags": { "command": "uvx", "args": ["mcp-gtags-server"] } } } ``` Or click the **Cursor one-click install** badge at the top of this page. On the very first tool call the server bootstraps everything else by itself: GNU Global (prebuilt user-space binaries โ€” no compiler, no sudo), universal-ctags, and Pygments, all into `~/.gtags-mcp`. While that one-time install runs (a few seconds on most platforms), tool calls return a *"toolchain is being installed โ€” retry shortly"* status instead of failing. Opt out with `--no-auto-setup` or `GTAGS_MCP_AUTO_SETUP=0`. The prebuilt Linux binaries run on any distro with glibc โ‰ฅ 2.28 (RHEL/Rocky 8+, Ubuntu 18.10+, Debian 10+). On older hosts โ€” or if a downloaded binary fails its post-install execution check โ€” setup automatically compiles GNU Global from source instead, which only needs `make` and a C compiler. ### Option B โ€” install the plugin (server + skill + slash commands) The plugin bundles this server, a **"C/C++ code navigation" skill** that tells the agent which tool answers which question, and two slash commands (`/gtags:impact`, `/gtags:explain`). Installing it registers the MCP server for you โ€” there is no config file to edit. | Client | Install | |---|---| | **Claude Code** | `/plugin marketplace add harshithsunku/mcp-gtags-server` then `/plugin install mcp-gtags-server@mcp-gtags-server` | | **Codex** | `codex plugin marketplace add harshithsunku/mcp-gtags-server`, then install it from the Plugins Directory in the ChatGPT desktop app | | **Cursor** | Loads [Agent Plugins](https://agent-plugins.org) 1.0.0, but installs only from the Cursor Marketplace or a team marketplace โ€” until this plugin is listed there, use the one-click badge or Option A | > **Install one way, not both.** A plugin *and* a manual entry run two servers, > and the agent sees every tool twice. Switching to the plugin? Remove the > manual entry first (`claude mcp remove gtags`). ### Option C โ€” one-line installer (shared background server) **One command. No sudo. Works everywhere** โ€” restricted corporate machines, containers, build servers: ```bash curl -fsSL https://raw.githubusercontent.com/harshithsunku/mcp-gtags-server/main/scripts/install.sh | bash ``` Everything lands in your home directory โ€” the server (via `uv`), GNU Global, universal-ctags, and Pygments (in `~/.gtags-mcp`). When it finishes, a **shared background HTTP server** is running that every client and IDE window on the machine can point at (`http://127.0.0.1:8383/mcp`), and the exact client configuration is printed to your console. **Re-run the same command any time:** - Up to date? โ†’ *"Already installed and up to date โ€” nothing to install"*, and the config is printed again. - New release on GitHub/PyPI? โ†’ the package updates, an outdated gtags toolchain is wiped and reinstalled automatically, and the background server restarts on the new version. ### Slash commands (MCP prompts) Available with any install, in clients that support MCP prompts (Claude Code, Cursor). They cost no tool-schema context: | Command | What it runs | |---|---| | `/gtags:impact [git_ref]` | `git diff` โ†’ the changed functions โ†’ `find_callers` on each โ†’ callers ranked by risk | | `/gtags:explain ` | definition (+ `#ifdef` variants) โ†’ body โ†’ callers โ†’ a short explanation | ### One config, many repos However you install it, **one user-level entry serves every repo you open** โ€” 20 repos need zero extra installs and zero extra config. Each tool call resolves its project root down this ladder: 1. `project_root` argument on the tool call (agents pass this to target any tree) 2. `--root` flag / `GTAGS_MCP_ROOT` env var 3. `root` in a [config file](#config-files) *(note: pinning a root here defeats multi-repo)* 4. **The client's workspace roots** (MCP roots protocol) โ€” IDEs that advertise their open folders get the right repo automatically, even on the shared HTTP server; with several folders open, agents are asked to pass `project_root`. Roots exist only for clients on protocol revisions up to 2025-11-25: the 2026-07-28 revision deprecated them, so those clients skip this step (stdio servers still resolve through step 5; on the shared HTTP server, agents pass `project_root`) 5. Walk up from the server's working directory to the nearest `.git`/`GTAGS` โ€” this is why stdio servers spawned by Claude Code/Cursor inside a repo just work Step 5 can be switched off with `--no-cwd-fallback` / `GTAGS_MCP_CWD_FALLBACK=0` (the plugin sets it): the tools then ask for `project_root` instead of indexing whatever directory the server happens to run in. Plugin clients launch the server from the plugin's own folder, which is never your project. That's it. No indexing step, no configuration. Ask your agent *"who calls `tcp_v4_rcv`?"* โ€” the first query in any repo builds that repo's index automatically, and every query after that is answered in milliseconds. Run `mcp-gtags-server doctor` any time to see what the server detects, or `mcp-gtags-server config` to re-print the client configuration.
Background server details (network access, port, lifecycle) The installer runs `mcp-gtags-server --transport http --host 127.0.0.1 --port 8383` in the background (pid: `~/.gtags-mcp/server.pid`, log: `~/.gtags-mcp/server.log`). Environment overrides for the installer: | Variable | Default | Meaning | |---|---|---| | `GTAGS_MCP_PORT` | `8383` | HTTP port | | `GTAGS_MCP_HOST` | `127.0.0.1` | Bind address โ€” set `0.0.0.0` to reach the server from other devices at `http://:8383/mcp` | | `GTAGS_MCP_NO_SERVER` | unset | `1` = don't start a background server | **Security note:** the HTTP endpoint is unauthenticated. It binds localhost by default; only bind `0.0.0.0` on networks you trust.
Manual install (prefer system packages, or already have Global) ```bash # 1. GNU Global โ€” EITHER user-space (no sudo): mcp-gtags-server setup # OR a system package: sudo apt install global # Debian/Ubuntu sudo dnf install global # Fedora brew install global # macOS # 2. The server: uv tool install mcp-gtags-server # or: pip install mcp-gtags-server ``` The server finds binaries in this order: `--bin-dir`/`GTAGS_MCP_BIN_DIR`/config `bin_dir` โ†’ `~/.gtags-mcp/bin` โ†’ `PATH` โ†’ `~/.local/bin`.
Claude Desktop โ€” extension bundle or manual config Easiest: download `mcp-gtags-server.mcpb` from the [latest release](https://github.com/harshithsunku/mcp-gtags-server/releases/latest), drag it into Claude Desktop's Settings โ†’ Extensions, and pick your project folder in the setup screen. The gtags toolchain installs itself on first use. Or add to `claude_desktop_config.json` manually (pin the project since Desktop doesn't launch in your repo): ```json { "mcpServers": { "gtags": { "command": "uvx", "args": ["mcp-gtags-server", "--root", "/absolute/path/to/your/project"] } } } ```
Pin a project root explicitly By default the server uses the client's advertised workspace root (MCP roots protocol) or auto-detects the project root by walking up from its working directory to the nearest `.git` or existing `GTAGS` โ€” so queries from anywhere inside a monorepo resolve to the repo root. Override with `--root /path`, the `GTAGS_MCP_ROOT` env var, or `root` in a config file โ€” or pass `project_root` on any individual tool call to query a different tree. (See ["One config, many repos"](#one-config-many-repos) for the full resolution order.)
Config files โ€” per-project and per-user defaults Every setting can also live in a TOML file, so teams share defaults through the repo (like `.editorconfig`): - **Project**: `.gtags-mcp.toml` at the project root - **User**: `~/.config/gtags-mcp/config.toml` ```toml # .gtags-mcp.toml label = "native-pygments" # force a GTAGSLABEL parser label bin_dir = "/opt/tools/bin" # extra directory searched for gtags/global/ctags skip_globs = ["*.gen.c"] # never index paths/basenames matching these globs respect_gitignore = true # default: index only what `git ls-files` reports enrich = true # default: ctags kind/signature/scope on results guards = true # default: #ifdef guard stacks on results macro_resolve = true # default: resolve macro-generated symbols (sys_*, ...) # root = "/abs/path" # default project root (user config) ``` Precedence: tool-call argument > CLI flag > environment variable > project config > user config > built-in default.
## Tools Eight tools, all read-only except `update_index`. Each takes `project_root` and `format` (`"text"` default, `"json"`); list-shaped tools also take `limit`/`offset`. Full parameter reference: [docs โ†’ Tools](https://harshithsunku.github.io/mcp-gtags-server/tools/). - **find_definition** โ€” Go to definition: every definition with its `#ifdef` guard stack and ctags kind/signature, plus a usage summary (reference/file counts, hottest files, `EXPORT_SYMBOL*` status). Resolves macro-generated names (`sys_read` โ†’ `SYSCALL_DEFINE3`). - **find_references** โ€” Every usage site with its guard stack; above 200 references it returns a per-file distribution, and `path_prefix` narrows it to one directory. - **get_symbol_body** โ€” The source of one definition (function, struct, or multi-line macro), not the whole file. - **find_callers** โ€” Who calls this function: references mapped to their enclosing functions, deduplicated, with call counts and call lines. - **find_callees** โ€” What this function calls: call sites taken from its body and checked against the index, split into in-tree (with locations) and external. - **reachability** โ€” The shortest static call chain from function A to function B, with the file:line of every hop, or "no static path". - **list_file_symbols** โ€” Every symbol a file defines (its API surface). - **update_index** โ€” Synchronous index refresh after edits; `full=true` rebuilds from scratch. The only tool that writes. ### Symbol-level tools โ€” the noise killers **Give the agent the symbol, not the file.** | Tool | What the agent gets | |---|---| | `get_symbol_body` | **Just the source of a definition.** The 271-line `tcp_v4_rcv` function โ€” not the 3,500-line file it lives in. Handles functions, structs, and multi-line macros. | | `find_callers` | **The call graph, deduplicated.** Every reference mapped to its enclosing function with call counts: 245 raw lines for `ext4_mark_inode_dirty` collapse to 62 callers. Iterate it to walk the caller graph as deep as you need. | | `find_callees` | **The outgoing call graph.** What does this function call? Body-extracted call sites, each verified against the index, split into in-tree (with locations) and external. | | `reachability` | **"Can this function end up in that one?"** โ€” BFS over the caller graph returns the *shortest* call chain from A to B with the file:line of every call site (`ksys_read โ†’ vfs_read โ†’ rw_verify_area`), or an honest "no static path" that names the function-pointer caveat. One call instead of a dozen find_callers rounds. | ### Core lookups | Tool | What it does | Underlying command | |---|---|---| | `find_definition` | **Where is this defined, and what is it?** Definitions with `#ifdef` guards and ctags metadata, plus a usage summary (reference/file counts, hottest files, `EXPORT_SYMBOL*` status). Falls back to macro-family resolution (`sys_*`, `trace_*`, `DEFINE_*` names) and suggests prefix matches on a miss | `global -x` + [macro resolution](#macro-generated-symbols-resolve-too-sys_read--syscall_define3) | | `find_references` | Every usage site, each with its guard stack. Above 200 references it returns a **per-file distribution** instead of a wall of lines (`kmalloc`'s 2,744 references become one screen); `path_prefix` drills into one directory, `group_by` forces either view. Falls back to symbol-usage records (libc calls, some variables), flagged `fallback` | `global -rx -S `, fallback `global -sx` | | `list_file_symbols` | A file's API surface โ€” every symbol it defines | `global -fx` | | `update_index` | Synchronous freshness barrier after edits; `full=true` rebuilds from scratch (rarely needed โ€” indexing is automatic) | `gtags -i` / `gtags` | Every query tool supports `limit`/`offset` pagination, long-line truncation, and (where it makes sense) `case_insensitive` โ€” output is *engineered* to never flood a context window. Every tool also declares MCP tool annotations: all are `readOnlyHint: true` except `update_index`, and none reach outside your machine (`openWorldHint: false`). Clients use these hints to auto-approve and parallelize read-only calls. Each response carries the envelope exactly once, as text content, with no duplicate `structuredContent` copy. ### The flow that saves your context window ```text 1. find_definition("kmalloc") โ†’ definitions + #ifdef variants + usage spread (3 lines) 2. find_callers("ext4_mark_inode_dirty") โ†’ deduped callers, each with its call line 3. get_symbol_body("tcp_v4_rcv") โ†’ read the ONE function that matters 4. find_callees("tcp_v4_rcv") โ†’ what it depends on, with locations 5. reachability("ksys_read", "rw_verify_area") โ†’ the call chain, one line per hop 6. find_references("mutex_lock", path_prefix="fs/ext4") โ†’ hot symbol, one subsystem ``` A few hundred lines of context total โ€” versus tens of thousands for the grep-and-read-files equivalent. ## Output and kernel smarts ### Output: compact text by default, JSON on request Since **v2.0.0** every tool answers with grep-shaped text โ€” the format agents read best, and 2โ€“3ร— cheaper in tokens than the same answer as JSON: ```text kmap: 4 definitions under 3 #if variants ยท 261 refs in 65 files (top: tools/perf/util/machine.c 26, โ€ฆ) include/linux/highmem-internal.h:40: static inline void *kmap(struct page *page) [function; #if CONFIG_HIGHMEM] include/linux/highmem-internal.h:170: static inline void *kmap(struct page *page) [function; #if !CONFIG_HIGHMEM] next: get_symbol_body, find_callers, find_references ``` `path:line: source` rows carry a tag with what the symbol is and the `#if`/`#ifdef` stack it lives under. Paginated results end with a footer like `[1-100 of 5290 ยท offset=100 for more]`. Against v1.x's JSON default the same seven kernel questions cost **20% fewer tokens** overall โ€” up to 68% on individual lookups. `find_callers` is the one call that got *bigger* (about 9%), on purpose: it now includes each call site's source line, which is exactly what agents used to spend an extra grep to see. Pass `format="json"` for the machine-readable envelope โ€” the same data, rendered from one source of truth: ```json { "tool": "find_definition", "root": "/abs/project/root", "results": [ {"symbol": "kmap", "path": "include/linux/highmem-internal.h", "line": 40, "col": 22, "kind": "function", "typeref": "void *", "scope": null, "signature": "(struct page * page)", "guard": ["CONFIG_HIGHMEM"], "snippet": "static inline void *kmap(struct page *page)"} ], "total": 2, "offset": 0, "truncated": false, "definition_count": 2, "guard_variants": 2, "reference_count": 261, "file_count": 65, "top_files": [{"path": "โ€ฆ", "count": 26}], "exported": null, "next_tools": ["get_symbol_body", "find_callers", "find_references"], "warning": null } ``` - Symbol locations always use the stable record schema `{symbol, path, line, col, kind, typeref, scope, signature, guard, snippet}` with repo-relative paths. Keys are only ever added, never renamed or removed within a major version. - **`kind` / `typeref` / `scope` / `signature` say *what* a symbol is** (since v0.8.1): function vs. macro vs. struct vs. typedef vs. enum constant, its return/target type, its enclosing scope (`enum:color`, `struct:item`), and its parameter list โ€” extracted per file by universal-ctags with **no build and no compile database**, cached, and filled on definition-shaped results (`find_definition`, `list_file_symbols`). When universal-ctags isn't available the fields are simply `null`; disable explicitly with `--no-enrich`, `GTAGS_MCP_ENRICH=0`, or `enrich = false` in `.gtags-mcp.toml`. - **`guard` says *when* a symbol exists** (since v0.9.0): the enclosing `#if`/`#ifdef` stack, outermost first (`[]` = unconditional, `null` = scanning disabled or file unreadable). See the next section โ€” this is the headline feature. - **`resolved_via` says *how* a symbol was found** when it took macro-family resolution rather than a literal index match (`"macro:SYSCALL_DEFINE"`, `"fuzzy:vfs_read"`) โ€” see [Macro-generated symbols](#macro-generated-symbols-resolve-too-sys_read--syscall_define3). - `next_tools` tells the agent the highest-value follow-up call for what was (or wasn't) found. - `total`/`offset`/`truncated` drive pagination; failures keep the envelope with an `error` field and are flagged `isError` on the protocol so the agent can self-correct. - Composite tools return tool-shaped `results` (e.g. `find_callees` `{in_tree, external}`, `reachability` a hop chain) inside the same envelope. - Every tool declares MCP **tool annotations**: all are `readOnlyHint: true` except `update_index`, and none reach outside your machine (`openWorldHint: false`) โ€” clients use these to auto-approve and parallelize calls. **Upgrading from v1.x?** Pass `format="json"` explicitly wherever you parsed the old default. `symbol_info` is now part of `find_definition`, `summarize_references` is `find_references` (it groups by file automatically), and `blast_radius` is the `/gtags:impact` prompt โ€” `git diff`, then `find_callers` on each changed function. ### `#ifdef`-aware: know which definition your config actually compiles Kernel and firmware code defines the same symbol multiple times and lets the build configuration pick one. Every other no-build tool returns a flat, unexplained list โ€” an agent happily reads the no-op stub of `kmap` and reasons its way to a wrong answer. This server reads the preprocessor conditionals (pure scanning โ€” still no build, no `compile_commands.json`): ```text Symbol: kmap 2 definitions under 2 distinct guards: [CONFIG_HIGHMEM] defined at include/linux/highmem-internal.h:40 โ€” function kmap(struct page * page) -> void * [!CONFIG_HIGHMEM] defined at include/linux/highmem-internal.h:170 โ€” function kmap(struct page * page) -> void * ``` Pass `active_config` โ€” a kernel `.config` path or a macro list like `"CONFIG_SMP,BITS_PER_LONG=64,!CONFIG_DEBUG"` โ€” to `find_definition`, or `find_references`, and definitions whose guard stack is **definitely false** under it are dropped (the envelope reports the count as `config_filtered`). Filtering is deliberately conservative: a `.config` is a closed world for `CONFIG_*` macros (kbuild semantics, including `=m` โ†’ `CONFIG_X_MODULE` and `IS_ENABLED`/`IS_BUILTIN`/`IS_MODULE`), but anything unknown (`__ASSEMBLY__`, `ARCH_HAS_*`, arithmetic it can't decide) never drops a result. The details are handled so the output stays clean: classic include guards (`#ifndef FOO_H`) are detected and suppressed, `#elif` chains compose into explicit conditions (`!CONFIG_X86_64 && CONFIG_X86_32`), comments on directives are ignored (they lie), and broken/partial files never fail a query. Disable with `--no-guards`, `GTAGS_MCP_GUARDS=0`, or `guards = false` in `.gtags-mcp.toml`. ### Macro-generated symbols resolve too (`sys_read` โ†’ `SYSCALL_DEFINE3`) The best-known gap of every tagging tool on the kernel: `sys_read`, `trace_sched_switch`, and `css_set_lock` have **no literal definition anywhere** โ€” they are minted by token-pasting macros, and a plain lookup comes back empty (or worse, returns a same-named test helper). This server resolves them from the index alone, no preprocessor and no build: ```text find_definition("sys_read") โ†’ fs/read_write.c:723 SYSCALL_DEFINE3(read, ...) resolved_via: "macro:SYSCALL_DEFINE" find_definition("__x64_sys_openat")โ†’ fs/open.c:1385 SYSCALL_DEFINE4(openat, ...) (arch entry-point wrappers map back) find_definition("trace_sched_switch") โ†’ include/trace/events/sched.h:220 TRACE_EVENT(sched_switch, ...) find_definition("css_set_lock") โ†’ kernel/cgroup/cgroup.c:82 DEFINE_SPINLOCK(css_set_lock); ``` Covered families: `SYSCALL_DEFINE0..6` / `COMPAT_SYSCALL_DEFINE*` (incl. `__x64_`/`__ia32_`/`__arm64_`/`__se_`/`__do_` wrapper spellings), tracepoints (`TRACE_EVENT`, `DEFINE_EVENT`, `DECLARE_TRACE`, ...), and the bare-name definers (`DEFINE_SPINLOCK`, `DEFINE_MUTEX`, `DEFINE_PER_CPU*`, `DECLARE_BITMAP`, `module_param*`, any `DEFINE_`/`DECLARE_`-shaped macro) โ€” plus a last-resort fuzzy tier that tries underscore-variant spellings. Resolved results are flagged with `resolved_via` in the envelope and ranked ahead of same-named textual shadows; `DEFINE_*` sites rank above their `DECLARE_*` counterparts. `find_definition` additionally reports the `EXPORT_SYMBOL` / `EXPORT_SYMBOL_GPL` variant a kernel symbol is exported with, in an `exported` field. Costs nothing when a symbol resolves normally (only family-shaped names like `sys_*`/`trace_*` get the extra indexed lookups); disable with `--no-macro-resolve`, `GTAGS_MCP_MACRO_RESOLVE=0`, or `macro_resolve = false`. ### What gets indexed (junk stays out) Indexing feeds `gtags` an explicit file list instead of letting it walk the tree: - In a git repository the list comes from `git ls-files` โ€” **`.gitignore` is respected exactly**, so build output, vendored blobs, and generated files never pollute the index. Disable with `respect_gitignore = false` in `.gtags-mcp.toml`. - Outside git, the tree is walked minus well-known junk directories (`.git`, `node_modules`, `build`, `dist`, `.venv`, ...). - `skip_globs` in `.gtags-mcp.toml` drops anything else you never want indexed. Incremental refreshes recollect the list, so newly ignored files drop out of the index and new files appear โ€” automatically. ## Multi-language projects (C + Python + more) Real projects mix languages โ€” a C core with Python tooling, JS frontends, Go services. The server handles this automatically: - **Native languages** (C, C++, Java, PHP, Yacc, assembly) use GNU Global's fast built-in parser. - **Everything else** (Python, Go, Rust, JavaScript, TypeScript, Ruby, ... ~150 languages) is indexed through Global's **ctags + Pygments plugin parsers** โ€” same index, same tools, same queries. The one-line installer (and `mcp-gtags-server setup`) enables this automatically โ€” it installs universal-ctags and Pygments into user space, and the server switches to the `native-pygments` parser label on its own. Prefer system packages? Those work too: ```bash sudo apt install exuberant-ctags python3-pygments # Debian/Ubuntu sudo dnf install ctags python3-pygments # Fedora brew install ctags && pip install pygments # macOS ``` Now `find_definition("py_util")`, `get_symbol_body` (indentation-aware for Python), `find_callees`, `find_callers` โ€” all work across every language in the tree, in one index. Force a specific parser label with `--label`, `GTAGS_MCP_LABEL`, or `label` in `.gtags-mcp.toml` (e.g. `default` for native-only, `pygments` for plugin-everything). **Honest caveats:** for plugin-parsed languages, *definitions* are as accurate as ctags, but *references* are token-based โ€” every occurrence of the name counts, without C-grade semantic reference tracking or local-scope awareness. For C/C++ nothing changes: the native parser still does that part. ## How it works ### Architecture One long-lived server process per IDE window (stdio) โ€” or one shared HTTP server for the whole machine. Everything heavy lives on disk and is shared: the toolchain installs itself once per machine, the index builds itself once per repo. ```mermaid flowchart LR subgraph clients["MCP clients"] CC["Claude Code / Cursor / VS Code"] CD["Claude Desktop (.mcpb)"] end subgraph proc["mcp-gtags-server โ€” spawned once per window, lives for the session"] T["11 navigation tools"] RR["root resolution
project_root โ†’ env โ†’ config โ†’ client roots โ†’ cwd"] end subgraph disk["your machine, user space (no sudo)"] TC["~/.gtags-mcp
global ยท gtags ยท ctags ยท Pygments
self-installs on first use"] IX["<repo>/.gtags-mcp/GTAGS
one index per repo, auto-built"] end CC -- "stdio (uvx)" --> T CD -- "stdio" --> T T --> RR RR -- "runs global (ms, indexed)" --> TC TC -- "B-tree lookup" --> IX ``` ### A tool call, end to end The binary is **not** re-executed per call โ€” the server process stays alive; each call is one JSON-RPC message plus one millisecond-scale `global` subprocess: ```mermaid sequenceDiagram participant A as Agent participant S as server (long-lived process) participant G as global (GTAGS index) A->>S: find_definition("tcp_v4_rcv") Note over S: first call on this machine?
toolchain installs itself in the background,
calls answer "installing โ€” retry shortly" Note over S: first query in this repo?
index auto-builds once (~66 s for the kernel) S->>G: global -dx tcp_v4_rcv G-->>S: net/ipv4/tcp_ipv4.c:2067 (milliseconds) S-->>A: narrow JSON answer โ€” no grep firehose ``` - **First query on a tree?** The index is built automatically (the only operation that ever blocks โ€” and only once). - **Where do the index files go?** Into a single `.gtags-mcp/` folder at the project root โ€” never loose files next to your code. The folder ships its own `.gitignore`, so `git status` stays clean without touching yours. A pre-existing root-level `GTAGS` (from older versions, or your own gtags runs) keeps being used as-is. `mcp-gtags-server doctor` shows the location. - **Files changed?** A debounced incremental refresh runs **in the background**: queries always answer instantly from the current index while `gtags -i` catches up behind the scenes. Measured on the kernel: queries return in 0.02s while the 25s freshness check runs invisibly. Staleness is bounded by the debounce window; call `update_index` for a synchronous, guaranteed-fresh barrier right after edits. - **Huge result?** Pagination footers tell the agent exactly how to fetch the next page โ€” or the tool itself suggests a narrower one (`find_callers` on a symbol used in 500+ files points to `find_references`, which groups by file). ## FAQ **Why gtags instead of a language server (LSP)?** LSP servers give richer semantics but need a working build configuration, per-editor setup, and serious warm-up time on large trees. gtags indexes 37M lines in about a minute with *zero* configuration, handles the kernel-scale codebases LSPs choke on, and its fuzzy parser doesn't care whether the code currently compiles. For C/C++ navigation questions โ€” definition, references, callers โ€” it's the pragmatic sweet spot. (Wrapping clangd for the compile-DB case was considered and deliberately rejected: users who have a working `compile_commands.json` already have clangd and its ecosystem โ€” see [ROADMAP.md](ROADMAP.md).) **What languages?** C, C++, Yacc, Java, PHP, and assembly natively โ€” plus Python, Go, Rust, JS/TS, Ruby, and ~150 others via the ctags/Pygments plugin parsers (see [Multi-language projects](#multi-language-projects-c--python--more)). **Does the agent have to manage the index?** No. That's the point. Build-on-first-query, background refresh with adaptive debounce, zero blocking โ€” queries never wait for index maintenance. The explicit `update_index` tool exists only as an escape hatch: a synchronous freshness barrier after edits, and a from-scratch rebuild with `full=true`. **Where does the index live? Can I delete it?** In `.gtags-mcp/` at the project root (self-gitignored). Delete it freely any time โ€” the next query rebuilds it from scratch. **Will it fight my agent's built-in tools?** The tool descriptions are written to steer the model: they say *when* to use indexed lookups instead of grep. In practice agents pick the faster, narrower tool naturally. **`ModuleNotFoundError: No module named 'mcp.server.fastmcp'` on startup?** Releases up to v1.4.2 declared an uncapped `mcp` dependency, so installs made after the MCP Python SDK 2.0 release resolve an SDK they can't import. v1.4.3 fixes it by capping the SDK below 2.0, and v1.5.0+ runs natively on SDK 2.x (`mcp>=2.2,<3`), speaking every protocol revision from 2024-11-05 through 2026-07-28. Pick up the fix with `uvx --refresh mcp-gtags-server --help` (or `uvx mcp-gtags-server@latest`), or re-run the installer. If you must stay on an older release, pin the SDK yourself: `uvx --with 'mcp<2' mcp-gtags-server@1.4.2`. ## Development ```bash git clone https://github.com/harshithsunku/mcp-gtags-server cd mcp-gtags-server uv run --extra dev pytest # 325 tests; e2e tests auto-skip if GNU Global is absent npx @modelcontextprotocol/inspector mcp-gtags-server # poke at it interactively ``` Tests build a real C project in a temp dir and exercise auto-indexing, auto-refresh, caller mapping, body extraction, pagination, user-space binary discovery, and config layering end-to-end. Correctness is also measured, not assumed: `mcp-gtags-server eval --golden evals/golden.jsonl --root ` runs a 64-case golden set covering all 8 tools (definitions, macro resolution, references, callers, callees, bodies, guards, reachability, maintenance) against a real kernel and prints recall / precision@1 โ€” CI does this weekly against a pinned tag. See [docs/capability.md](docs/capability.md) for the current numbers. Release flow: bump `version` in `pyproject.toml`, tag `vX.Y.Z`, push โ€” CI publishes to PyPI and users pick the update up on their next installer re-run. Prebuilt GNU Global binaries are rebuilt by tagging `global-v` (or `gh workflow run release-binaries.yml -f version=` to replace the assets of an existing release in place); Linux builds run inside manylinux_2_28 containers so they work on any glibc โ‰ฅ 2.28 host, enforced by a CI symbol-ceiling check. ## Roadmap See [ROADMAP.md](ROADMAP.md) โ€” structured JSON output landed in v0.8.0, ctags metadata enrichment (kind/signature/scope) in v0.8.1, `#ifdef`/config-guard awareness (the headline capability for kernel and firmware trees) in v0.9.0, then, all shipped in v1.0.0: macro-family symbol resolution (`sys_read` โ†’ its `SYSCALL_DEFINE3` site), the agent workflow tools (`reachability`, diff-impact analysis), automatic recovery from corrupted index databases, and the correctness eval harness โ€” a 64-case golden set against pinned kernel v6.16 scoring 100% recall / 100% precision@1 in CI, with the measured writeup in [docs/capability.md](docs/capability.md). Every technical milestone is done; what remains is distribution (MCP registry, directories, the writeup post). Contributions welcome โ€” open an issue or PR. ## License [MIT](LICENSE) ยฉ Harshith Sunku