AST_MCP
Structural code retrieval over MCP. Parses source with tree-sitter and serves
symbols instead of files, so an agent asking "what does parse_config do"
gets 40 lines rather than 2000.
Read-only. The server never writes to your source — the only file it writes is
its own index at .ast_mcp/index.db.
Install
uv tool install ast-mcp
Or run it without installing anything:
uvx ast-mcp --version
Requires Python 3.11 or newer.
Wire it into Claude Code
From the repository you want indexed:
ast-mcp init
That writes an ast-mcp stanza into <repo>/.mcp.json, adds .ast_mcp/ to
.gitignore, writes a short retrieval-routing block into CLAUDE.md, and
builds the index. Claude Code reads .mcp.json at startup, so the next
session in that directory has the tools already connected — no per-session
step.
The CLAUDE.md block is what makes the agent actually reach for the tools;
registration alone tends to leave them idle. It is about thirty lines and says
one thing: name a symbol, key path or heading → these tools; describe
behaviour you cannot name → your semantic retriever; about to edit a file, or
need it byte-for-byte → Read. --no-claude-md skips it.
init merges. Every other server in .mcp.json is left exactly as it was,
and a file it cannot parse is reported rather than overwritten. Re-running it
is a no-op. Pass --dry-run to see the change first.
The stanza it writes carries no absolute paths, so it is safe to commit:
{
"mcpServers": {
"ast-mcp": { "command": "ast-mcp", "args": ["serve"] }
}
}
If ast-mcp is not permanently on PATH it records uvx ast-mcp serve
instead. Override either with --command "uv run ast-mcp serve".
CLI
| command | what it does |
|---|---|
ast-mcp init |
register in .mcp.json, write the CLAUDE.md routing block, ignore the index dir, build the index |
ast-mcp index [--rebuild] |
build or refresh the index; --rebuild discards it first |
ast-mcp status [--json] |
file/symbol counts, index size, freshness, registration |
ast-mcp savings [--json] [--reset] |
tokens served vs. what reading those files whole would have cost |
ast-mcp languages [--group G] |
the language registry — 27 rows, their extensions and profiles |
ast-mcp serve |
the MCP server over stdio; what Claude Code launches |
Every command takes --root PATH. Root resolution is --root, else
AST_MCP_ROOT, else the working directory. Bare ast-mcp means ast-mcp serve.
Only init writes anything outside .ast_mcp/, and only .mcp.json,
.gitignore and CLAUDE.md. The server itself never writes to your source.
Not every language gets the same treatment
A .py file has functions with signatures and docstrings. A
docker-compose.yml has none of that — it has a shape. A README.md has a
heading tree. Forcing all three through one symbol model produces garbage for
two of them, so there are four extraction profiles:
| profile | payload key | what you get | languages |
|---|---|---|---|
symbols |
symbols |
signature, docstring, nesting, imports | python, javascript, typescript, tsx, go, lua, csharp |
defs |
symbols |
same shape, weaker guarantees — docstrings often null |
ruby, perl, r, bash, zsh, css, scss, sql, graphql, proto, terraform, dockerfile |
schema |
schema |
key paths + inferred value types, not funcdefs | json, json5, yaml, toml, xml, csv |
outline |
outline |
heading / section tree | markdown, html |
27 languages across six groups: core, web, scripting, data, devops,
docs. Every response declares its profile and group before the
payload — read that field, don't assume symbols exists.
Kinds never lie about fidelity. A schema node's kind is a value type
(object, array, string); an outline node's kind is a document structure
(heading, code_block). Nothing outside the symbols/defs profiles ever
claims to be a function or a class.
Tools
| tool | use it for |
|---|---|
file_outline(path, max_depth, include_docstrings, mode) |
the Read replacement — a file's shape, bodies elided. mode="names" returns names, lines and counts only |
get_symbol(name, path, mode) |
one definition. name is a symbol name, a key path (services.web.ports), or a heading slug |
search_symbols(query, kind, lang, group, path_glob, limit) |
find things by name across the repo |
get_docstrings(path | symbols) |
docs without bodies |
list_imports(path) |
dependency edges out of a file, plus exports where the language has them |
ast_query(path, query, captures) |
raw tree-sitter S-expression — the escape hatch |
Every tool takes max_tokens (default 4000). An over-budget response is
trimmed, flagged truncated: true, and tells you which argument narrows it.
Nothing is dropped silently.
get_symbol never guesses. A name matching several symbols returns
ambiguous: true with candidates; pass path or a qualified name to resolve.
Alongside a semantic retriever (optional)
AST_MCP stands alone. Nothing it tells the agent assumes another retrieval tool is registered, and a repo running only this server is a supported setup.
If you also run CCE, --with-cce adds one paragraph to the server's
instructions so the agent knows how to split the work:
ast-mcp init --with-cce # records the flag in .mcp.json
context_search(CCE) — fuzzy semantic retrieval over embedded chunks. For "how does auth work?", "where is rate limiting handled?" — you know the concept but not the name.- AST_MCP — exact structural retrieval by name, kind and range. For
"show me
TokenStore.refresh", "what's in this config file?", "list everyCREATE TABLEin the repo" — you know the name but not the location.
Rough rule: describing behaviour → context_search. Naming a thing →
AST_MCP. A context_search hit hands you a name; get_symbol turns it into
the exact definition.
Without the flag nothing changes and no CCE mention reaches the agent. Plain
ast-mcp init prints a hint if it notices context-engine in .mcp.json;
it never switches modes for you.
One block, not two
CCE writes its own CLAUDE.md instructions, and they say to use
context_search instead of reading files — with no mention of this server, so
a named symbol gets routed to the semantic retriever too. Two blocks means two
contradictory rules, and the loud one wins.
So init replaces that block rather than adding a second one. It reuses CCE's
own <!-- cce-block-version: N --> markers and keeps the value of N it finds
on disk. CCE decides whether to rewrite by testing for its current tag, so a
later cce init sees a match and leaves the file alone. Run cce init first,
then ast-mcp init.
If CCE later bumps that version and reclaims the slot, ast-mcp status says
so — re-run ast-mcp init to take it back. Nothing outside the marked block
is touched, at any point.
What it costs
The saving grows with file size — the response envelope is fixed cost, so small files benefit least. Measured on this codebase:
| file | lines | full read | outline | saving |
|---|---|---|---|---|
ast_mcp/render.py |
94 | 789 tok | 352 tok | 2.2x |
ast_mcp/index.py |
428 | 3994 tok | 967 tok | 4.1x |
ast_mcp/tools.py |
457 | 3882 tok | 690 tok | 5.6x |
Below roughly 100 lines it is about break-even against Read. Above that it
pays, and get_symbol on a single definition pays regardless.
A 5000-row CSV or JSON array costs the same as a 3-row one: homogeneous
repeats collapse to one node carrying children_count. The collapse says how
far to trust it — uniformity is uniform, mixed, or unverified (over
5000 elements), checked across the element key sets. No value from inside a
collection comes back with it, so asking a 1.3 MB ban_list.json for its
record shape returns the shape and none of the records.
Measure it on your own repo
Every tool response is logged to .ast_mcp/savings.db — what it served, and
what a whole-file read of every file it cited would have cost.
$ ast-mcp savings
root /home/you/project · 5 queries · last query 2m ago
▰▰▰▰▰▰▰▰▰▱ 90% of a whole-file read saved
whole-file reads 38.5k tokens
served by ast-mcp 3.9k tokens
──────────────────────────────────────
saved 34.6k tokens $0.52
~6.9k tokens / query ~$0.10 / query
by tool:
search_symbols 62% ▰▰▰▰▰▰▱▱▱▱ 21.5k $0.32 · 1 call
file_outline 21% ▰▰▱▱▱▱▱▱▱▱ 7.3k $0.11 · 2 calls
--json for the same numbers machine-readably, --reset to start the count
over. Pricing is Opus input ($15/1M); set AST_MCP_PRICE_PER_MTOK for another
model, or AST_MCP_NO_STATS=1 to record nothing at all.
Freshness
The SQLite index is a cache, never an oracle. Every path in a response was
stat-checked against its recorded (mtime_ns, size) during that same call,
and reparsed if it moved. There is no watcher daemon and no stale window.
Development
git clone https://github.com/matthew-brough/AST_MCP && cd AST_MCP
uv sync --all-groups
uv run --group dev pytest -q
The suite runs on 3.11 through 3.14. uv build produces the wheel; the
.scm query files ship inside it, and CI fails the build if any are missing.
SPEC.md is the contract: §V invariants, §I interfaces, §T tasks, §B the log
of what went wrong and what changed because of it.
Metadata
Release files for ast-mcp 0.7.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| ast_mcp-0.7.2.tar.gz | 115.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ast_mcp-0.7.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 178.4 kB
Release files / ast_mcp-0.7.2.tar.gz
| Download URL | ast_mcp-0.7.2.tar.gz |
|---|---|
| Size | 115.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
54ba617d3de7b95e419209232ad79f844b4892fc953d8c3186ffa8807f6e8ebb
|
|
BLAKE2b-256 checksum How to use checksums |
eca9e64f71e65c557f0a79a049be74b5804d687f073030136a59ddb8ff5dbda8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / ast_mcp-0.7.2-py3-none-any.whl
| Download URL | ast_mcp-0.7.2-py3-none-any.whl |
|---|---|
| Size | 62.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
be0489cd79101dd213f387c972a9bb1a87109f4b7b09ca09d50c71684303218c
|
|
BLAKE2b-256 checksum How to use checksums |
35f272ce12ecce3c069cfd288363c96a3ea97f3a1fea1a349f28032d7d8eec2d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for ast-mcp 0.7.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.