Skip to content

Commit f00b2cb

Browse files
harshithsunkuclaude
andcommitted
v0.8.1: ctags metadata enrichment — kind/typeref/scope/signature on results
Roadmap milestone 2. New gtags_mcp.enrich module runs Universal Ctags (JSON output) per file to fill the record fields reserved in v0.8.0: symbol kind (function/macro/struct/typedef/enumerator/...), return or target type, enclosing scope, and parameter signature — with no build and no compile database. - find_definition, list_file_symbols, and symbol_info are enriched; symbol_info's card now says WHAT a symbol is: function tcp_v4_rcv(struct sk_buff * skb) -> int - Best-effort by design: without Universal Ctags (+json) every field is null and output is identical to v0.8.0. Capability probe checks the binary's feature list; Exuberant ctags is detected and skipped. - Bounded cost: enrichment runs after pagination (one page max), one ctags run per distinct file, 256-entry LRU keyed by mtime+size with negative caching; 8 MB size cap and 10 s timeout. - Anonymous enum/struct scopes render as "<anonymous>" instead of ctags' "__anon<hash>" noise. - Opt out with --no-enrich, GTAGS_MCP_ENRICH=0, or enrich = false in .gtags-mcp.toml; doctor reports enrichment status. - Record schema grows typeref/scope/signature (additive; guard still reserved for milestone 3). 34 new tests (95 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 50c0b17 commit f00b2cb

11 files changed

Lines changed: 904 additions & 37 deletions

File tree

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ label = "native-pygments" # force a GTAGSLABEL parser label
162162
bin_dir = "/opt/tools/bin" # extra directory searched for gtags/global/ctags
163163
skip_globs = ["*.gen.c"] # never index paths/basenames matching these globs
164164
respect_gitignore = true # default: index only what `git ls-files` reports
165+
enrich = true # default: ctags kind/signature/scope on results
165166
# root = "/abs/path" # default project root (user config)
166167
```
167168

@@ -177,7 +178,7 @@ Precedence: tool-call argument > CLI flag > environment variable > project confi
177178

178179
| Tool | What the agent gets |
179180
|---|---|
180-
| `symbol_info` | **A one-shot overview card** — definitions, reference count, hottest files, and which tool to use next. The best first query for any unfamiliar symbol. |
181+
| `symbol_info` | **A one-shot overview card** — definitions (with kind, signature, and scope), reference count, hottest files, and which tool to use next. The best first query for any unfamiliar symbol. |
181182
| `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. |
182183
| `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. |
183184
| `call_hierarchy` | **Multi-level impact analysis.** Who calls X, who calls *those*, up to 5 levels — a cycle-safe, capped tree instead of N rounds of grep. |
@@ -225,15 +226,18 @@ Since v0.8.0 every tool returns a **machine-readable JSON envelope by default**
225226
"root": "/abs/project/root",
226227
"results": [
227228
{"symbol": "tcp_v4_rcv", "path": "net/ipv4/tcp_ipv4.c", "line": 2001,
228-
"col": 5, "kind": null, "guard": null, "snippet": "int tcp_v4_rcv(struct sk_buff *skb)"}
229+
"col": 5, "kind": "function", "typeref": "int", "scope": null,
230+
"signature": "(struct sk_buff * skb)", "guard": null,
231+
"snippet": "int tcp_v4_rcv(struct sk_buff *skb)"}
229232
],
230233
"total": 1, "offset": 0, "truncated": false,
231234
"next_tools": ["get_symbol_body", "find_callers", "symbol_info"],
232235
"warning": null
233236
}
234237
```
235238

236-
- Symbol locations always use the stable record schema `{symbol, path, line, col, kind, guard, snippet}` with repo-relative paths. `kind` (ctags metadata) and `guard` (`#ifdef` stack) are reserved for upcoming milestones and currently `null` — parsers never need to change shape.
239+
- 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 — parsers never need to change shape. `guard` (`#ifdef` stack) is reserved for an upcoming milestone and currently `null`.
240+
- **`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`, `symbol_info`, `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`.
237241
- `next_tools` tells the agent the highest-value follow-up call for what was (or wasn't) found.
238242
- `total`/`offset`/`truncated` replace the text continuation footer; errors keep the envelope with an `error` field.
239243
- Composite tools return tool-shaped `results` (e.g. `call_hierarchy` a nested caller tree, `find_callees` `{in_tree, external}`, `symbol_info` an overview object) inside the same envelope.
@@ -324,9 +328,10 @@ Release flow: bump `version` in `pyproject.toml`, tag `vX.Y.Z`, push — CI publ
324328

325329
## Roadmap
326330

327-
See [ROADMAP.md](ROADMAP.md) — structured JSON output landed in v0.8.0; next up are
328-
ctags metadata enrichment, `#ifdef`/config-guard awareness (the headline capability for
329-
kernel and firmware trees), macro-family symbol resolution, and a correctness eval harness.
331+
See [ROADMAP.md](ROADMAP.md) — structured JSON output landed in v0.8.0 and ctags
332+
metadata enrichment (kind/signature/scope on every definition) in v0.8.1; next up are
333+
`#ifdef`/config-guard awareness (the headline capability for kernel and firmware
334+
trees), macro-family symbol resolution, and a correctness eval harness.
330335

331336
Contributions welcome — open an issue or PR.
332337

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ Foundation for everything below; do this first so later tools inherit it.
4646
**Done when:** every tool can emit parseable JSON with repo-relative paths, and indexing
4747
a dirty tree skips junk automatically.
4848

49-
### 2. ctags metadata enrichment
49+
### 2. ctags metadata enrichment ✅ (v0.8.1)
5050
Cheap, high-value — uses the parser already in the stack.
5151

52-
- [ ] Surface ctags `kind`, `typeref`, `scope`, and `signature` for C/C++ symbols.
53-
- [ ] Enrich `symbol_info` cards: distinguish function / macro / struct / typedef /
52+
- [x] Surface ctags `kind`, `typeref`, `scope`, and `signature` for C/C++ symbols.
53+
- [x] Enrich `symbol_info` cards: distinguish function / macro / struct / typedef /
5454
enum-constant, show signature and enclosing scope.
5555

5656
**Done when:** `symbol_info` shows kind + signature + scope for C symbols with no build.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-gtags-server"
3-
version = "0.8.0"
3+
version = "0.8.1"
44
description = "Indexed code navigation for AI coding agents — replace grep scans with GNU Global (gtags) lookups over MCP. ~100x faster and radically less noise on million-line C/C++ codebases."
55
readme = "README.md"
66
requires-python = ">=3.10"

src/gtags_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""MCP server exposing GNU Global (gtags) code navigation for C/C++ codebases."""
22

3-
__version__ = "0.5.0"
3+
__version__ = "0.8.1"

src/gtags_mcp/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
bin_dir = "~/.gtags-mcp/bin" # extra directory searched for binaries
2121
skip_globs = ["*.gen.c", "third_party/*"] # never index matching paths
2222
respect_gitignore = true # use `git ls-files` to honour .gitignore
23+
enrich = true # ctags kind/signature/scope on results
2324
"""
2425

2526
from __future__ import annotations
@@ -35,7 +36,9 @@
3536

3637
PROJECT_CONFIG_NAME = ".gtags-mcp.toml"
3738

38-
_VALID_KEYS = frozenset({"root", "label", "bin_dir", "skip_globs", "respect_gitignore"})
39+
_VALID_KEYS = frozenset(
40+
{"root", "label", "bin_dir", "skip_globs", "respect_gitignore", "enrich"}
41+
)
3942

4043
# Caches: project configs keyed by directory, user config loaded once.
4144
_project_cache: dict[Path, dict] = {}

src/gtags_mcp/enrich.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""ctags metadata enrichment for symbol records (roadmap milestone 2).
2+
3+
Fills the ``kind`` / ``typeref`` / ``scope`` / ``signature`` record fields by
4+
running Universal Ctags directly on the files that appear in definition-shaped
5+
results — so ``symbol_info`` can say *what* a symbol is (function, macro,
6+
struct, typedef, enum constant, ...) with no build and no compile database.
7+
8+
Everything here is best-effort by design: when Universal Ctags with JSON
9+
output is not available, or a file cannot be parsed, records simply keep the
10+
``null`` metadata they had before v0.8.1. No tool ever fails, slows down
11+
noticeably, or changes shape because of enrichment.
12+
13+
Cost is bounded: at most one ctags run per distinct file per result page,
14+
cached in a small LRU keyed by (mtime, size) — including negative results, so
15+
a file ctags cannot handle is not re-parsed on every query.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
import re
22+
import subprocess
23+
import threading
24+
from collections import OrderedDict
25+
from pathlib import Path
26+
27+
from . import toolchain
28+
29+
CTAGS_TIMEOUT_SECONDS = 10
30+
# Belt and braces on top of the timeout: never feed ctags a generated blob.
31+
MAX_FILE_BYTES = 8 * 1024 * 1024
32+
CACHE_CAPACITY = 256
33+
34+
# ``--fields=*`` asks for everything ctags knows (we pick what we need, and
35+
# stay compatible if fields are reordered); ``+px`` adds prototypes/externs so
36+
# header declarations — which gtags may report as definitions — still match.
37+
CTAGS_ARGS = (
38+
"--output-format=json",
39+
"--fields=*",
40+
"--kinds-C=+px",
41+
"--kinds-C++=+px",
42+
"-o",
43+
"-",
44+
)
45+
46+
# When one name+line yields several tags (e.g. ``typedef struct foo foo;``
47+
# emits both a struct and a typedef tag), prefer the kind an agent most
48+
# likely asked about. Unlisted kinds rank last, ties break on list order.
49+
_KIND_PRIORITY = (
50+
"function",
51+
"typedef",
52+
"struct",
53+
"union",
54+
"enum",
55+
"class",
56+
"macro",
57+
"enumerator",
58+
"member",
59+
"variable",
60+
"prototype",
61+
"externvar",
62+
)
63+
64+
# ctags invents names like "__anon0416201d0103" for anonymous enums/structs;
65+
# hash noise helps nobody — render them as "<anonymous>" instead.
66+
_ANON_RE = re.compile(r"__anon[0-9a-fA-F]+")
67+
68+
# Capability probe results keyed by resolved ctags path: (usable, detail).
69+
_probe_cache: dict[str, tuple[bool, str]] = {}
70+
# LRU: absolute path -> ((mtime_ns, size), name -> [normalized tags]).
71+
_tags_cache: OrderedDict[str, tuple[tuple[int, int], dict[str, list[dict]]]] = (
72+
OrderedDict()
73+
)
74+
_lock = threading.Lock()
75+
76+
77+
def reset_cache() -> None:
78+
"""Forget probe results and cached tags (used by tests)."""
79+
with _lock:
80+
_probe_cache.clear()
81+
_tags_cache.clear()
82+
83+
84+
def _probe(exe: str) -> tuple[bool, str]:
85+
"""Check one ctags binary for Universal Ctags with JSON output support."""
86+
with _lock:
87+
if exe in _probe_cache:
88+
return _probe_cache[exe]
89+
try:
90+
proc = subprocess.run(
91+
[exe, "--version"],
92+
capture_output=True,
93+
text=True,
94+
errors="replace",
95+
timeout=CTAGS_TIMEOUT_SECONDS,
96+
)
97+
out = proc.stdout
98+
except (OSError, subprocess.SubprocessError):
99+
out = ""
100+
first = out.splitlines()[0].split(",")[0].strip() if out.strip() else exe
101+
if not out.strip():
102+
result = (False, f"{exe} did not run")
103+
elif "Universal Ctags" not in out:
104+
result = (False, f"{first} has no JSON output (Universal Ctags needed)")
105+
elif "+json" not in out:
106+
result = (False, f"{first} was built without +json support")
107+
else:
108+
result = (True, first)
109+
with _lock:
110+
_probe_cache[exe] = result
111+
return result
112+
113+
114+
def available(bin_dir: str | None = None) -> bool:
115+
"""True when a Universal Ctags with JSON output can be invoked."""
116+
exe = toolchain.find_ctags(bin_dir)
117+
return _probe(exe)[0] if exe else False
118+
119+
120+
def status_line(bin_dir: str | None = None) -> str:
121+
"""One human-readable line for the ``doctor`` subcommand."""
122+
exe = toolchain.find_ctags(bin_dir)
123+
if exe is None:
124+
return "metadata enrichment: unavailable (no ctags binary found)"
125+
usable, detail = _probe(exe)
126+
if usable:
127+
return f"metadata enrichment: active ({detail})"
128+
return f"metadata enrichment: unavailable ({detail})"
129+
130+
131+
def _normalize_tag(obj: dict) -> dict | None:
132+
"""One raw ctags JSON object -> {line, kind, typeref, scope, signature}."""
133+
line = obj.get("line")
134+
if obj.get("_type") != "tag" or not obj.get("name") or not isinstance(line, int):
135+
return None
136+
typeref = obj.get("typeref")
137+
if isinstance(typeref, str):
138+
if typeref.startswith("typename:"):
139+
# "typename:" is ctags noise; real refs like "struct:item" stay.
140+
typeref = typeref[len("typename:") :]
141+
typeref = _ANON_RE.sub("<anonymous>", typeref)
142+
scope = obj.get("scope")
143+
if isinstance(scope, str):
144+
if isinstance(obj.get("scopeKind"), str):
145+
scope = f"{obj['scopeKind']}:{scope}"
146+
scope = _ANON_RE.sub("<anonymous>", scope)
147+
return {
148+
"line": line,
149+
"kind": obj.get("kind"),
150+
"typeref": typeref if isinstance(typeref, str) else None,
151+
"scope": scope if isinstance(scope, str) else None,
152+
"signature": obj.get("signature") if isinstance(obj.get("signature"), str) else None,
153+
}
154+
155+
156+
def _run_ctags(exe: str, abs_path: Path) -> dict[str, list[dict]]:
157+
"""Run ctags on one file and index its tags by name. {} on any failure."""
158+
try:
159+
proc = subprocess.run(
160+
[exe, *CTAGS_ARGS, str(abs_path)],
161+
capture_output=True,
162+
text=True,
163+
errors="replace",
164+
timeout=CTAGS_TIMEOUT_SECONDS,
165+
)
166+
except (OSError, subprocess.SubprocessError):
167+
return {}
168+
if proc.returncode != 0:
169+
return {}
170+
tags: dict[str, list[dict]] = {}
171+
for raw in proc.stdout.splitlines():
172+
try:
173+
obj = json.loads(raw)
174+
except ValueError:
175+
continue
176+
if isinstance(obj, dict) and (tag := _normalize_tag(obj)):
177+
tags.setdefault(obj["name"], []).append(tag)
178+
return tags
179+
180+
181+
def tags_for_file(abs_path: Path, bin_dir: str | None = None) -> dict[str, list[dict]]:
182+
"""All tags ctags finds in one file, by symbol name. Cached; never raises."""
183+
try:
184+
stat = abs_path.stat()
185+
except OSError:
186+
return {}
187+
signature = (stat.st_mtime_ns, stat.st_size)
188+
key = str(abs_path)
189+
with _lock:
190+
cached = _tags_cache.get(key)
191+
if cached and cached[0] == signature:
192+
_tags_cache.move_to_end(key)
193+
return cached[1]
194+
if stat.st_size > MAX_FILE_BYTES:
195+
tags: dict[str, list[dict]] = {}
196+
else:
197+
exe = toolchain.find_ctags(bin_dir)
198+
if exe is None or not _probe(exe)[0]:
199+
return {}
200+
# The subprocess runs outside the lock; a rare duplicate ctags run on
201+
# a concurrent query is cheaper than serializing every enrichment.
202+
tags = _run_ctags(exe, abs_path)
203+
with _lock:
204+
_tags_cache[key] = (signature, tags)
205+
_tags_cache.move_to_end(key)
206+
while len(_tags_cache) > CACHE_CAPACITY:
207+
_tags_cache.popitem(last=False)
208+
return tags
209+
210+
211+
def _kind_rank(kind: str | None) -> int:
212+
try:
213+
return _KIND_PRIORITY.index(kind)
214+
except ValueError:
215+
return len(_KIND_PRIORITY)
216+
217+
218+
def best_tag(tags: dict[str, list[dict]], name: str, line: int) -> dict | None:
219+
"""The tag that matches a (name, line) result record, or None.
220+
221+
Exact line match first (kind-priority tie-break for multi-tag lines like
222+
``typedef struct foo foo;``); when the name is unique in the file, accept
223+
it at any line — gtags' parser and ctags occasionally disagree by a line.
224+
"""
225+
entries = tags.get(name)
226+
if not entries:
227+
return None
228+
exact = [t for t in entries if t["line"] == line]
229+
if exact:
230+
return min(exact, key=lambda t: _kind_rank(t["kind"]))
231+
if len(entries) == 1:
232+
return entries[0]
233+
return None

src/gtags_mcp/output.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@
1313
1414
Symbol-location items use one stable record schema everywhere::
1515
16-
{ "symbol", "path", "line", "col", "kind", "guard", "snippet" }
17-
18-
``kind`` (ctags metadata) and ``guard`` (#ifdef stack) are reserved for
19-
later milestones and always present as ``null`` so agent-side parsers never
20-
change shape. Paths are repo-relative. Errors replace ``results`` with an
21-
``error`` string but keep the envelope and ``next_tools``.
16+
{ "symbol", "path", "line", "col",
17+
"kind", "typeref", "scope", "signature", "guard", "snippet" }
18+
19+
``kind`` / ``typeref`` / ``scope`` / ``signature`` are ctags metadata
20+
(populated on definition-shaped results when Universal Ctags with JSON
21+
output can parse the file — see :mod:`gtags_mcp.enrich` — and ``null``
22+
otherwise). ``guard`` (#ifdef stack) is reserved for a later milestone and
23+
always ``null``. Keys are only ever *added*, never renamed or removed, so
24+
agent-side parsers keep working. Paths are repo-relative. Errors replace
25+
``results`` with an ``error`` string but keep the envelope and
26+
``next_tools``.
2227
"""
2328

2429
from __future__ import annotations
@@ -70,7 +75,17 @@ def next_tools(tool: str, has_results: bool) -> list[str]:
7075
return list(on_hit if has_results else on_empty)
7176

7277

73-
def record(symbol: str, path: str, line: int, snippet: str) -> dict:
78+
def record(
79+
symbol: str,
80+
path: str,
81+
line: int,
82+
snippet: str,
83+
*,
84+
kind: str | None = None,
85+
typeref: str | None = None,
86+
scope: str | None = None,
87+
signature: str | None = None,
88+
) -> dict:
7489
"""One symbol-location result in the stable record schema."""
7590
snippet = snippet.rstrip()
7691
if len(snippet) > MAX_SNIPPET_CHARS:
@@ -81,7 +96,10 @@ def record(symbol: str, path: str, line: int, snippet: str) -> dict:
8196
"path": path[2:] if path.startswith("./") else path,
8297
"line": line,
8398
"col": idx + 1 if idx >= 0 else None,
84-
"kind": None, # ctags enrichment (roadmap milestone 2)
99+
"kind": kind,
100+
"typeref": typeref,
101+
"scope": scope,
102+
"signature": signature,
85103
"guard": None, # #ifdef stack (roadmap milestone 3)
86104
"snippet": snippet,
87105
}

0 commit comments

Comments
 (0)