py-ast-mcp
# py-ast-mcp
An MCP (Model Context Protocol) server for deep structural analysis of **Python** source code.
It is the Python counterpart to [ts-ast-mcp](https://github.com/gclluch/ts-ast-mcp) and mirrors its 20-tool surface as closely as Python semantics allow. Everything is built on the standard library `ast` module — no compilation, no type checker, no project configuration required. `jedi` is an optional extra used only for cross-file semantic resolution, and every tool degrades gracefully when it is not installed.
Unlike `grep`, this server understands actual structure: function signatures, class hierarchies, call relationships, cyclomatic complexity and unreferenced code.
## Features
- **Real AST, not regex** — signatures with annotations and defaults, decorators, async flags, positional-only and keyword-only parameters, nested definitions.
- **Python-aware classification** — dataclasses, enums, `Protocol`, `TypedDict`, `NamedTuple`, ABCs, exceptions and `TypeAlias` are recognised as distinct kinds.
- **Call graphs as Mermaid** — file-scoped or package-scoped flowcharts, rooted at a function, with optional external call edges.
- **Quality tooling** — cyclomatic complexity with ranks, scored to match `radon`/`mccabe`, code smells, and Python-specific hazards (mutable default arguments, mutable `@dataclass` field defaults, bare `except`, unreachable `except` clauses, late-binding closures, `is` against a literal, unawaited coroutines, `assert` used for validation).
- **Dead code across a directory** — unreferenced private and module-level symbols, with a confidence split between private and public names.
- **Protocol conformance** — finds implementations both explicitly (base class, including indirect subclasses) and structurally (method-set match).
- **Docstring parsing** — Google and NumPy styles are split into summary / params / returns / raises.
- **Structural diffing** — signature-level, not text-level: what actually changed in the API.
- **Model-friendly output** — dense, scannable plain text with line numbers everywhere, not raw JSON dumps.
- **Never crashes on bad input** — syntax errors come back as a readable error with line and column, flagged `isError` so a client can tell failure from analysis; the server stays up.
- **Parse caching** — modules are cached by path + mtime + size, so repeated tool calls in one turn are cheap.
## Installation
Requires Python 3.10+. Prefer the newest Python you have installed: the server can only parse syntax its own interpreter understands (see [Known limitations](#known-limitations)).
```bash
git clone <this-repo> py-ast-mcp
cd py-ast-mcp
pip install -e .
# optional: cross-file semantic resolution
pip install -e ".[semantic]"
# development (pytest + jedi)
pip install -e ".[dev]"
```
This installs a `py-ast-mcp` console script that runs the stdio server. `python -m py_ast_mcp` works too.
## Tools
All `path` arguments accept absolute paths or paths relative to the server's working directory.
### Structural
| Tool | Parameters | Description |
| --- | --- | --- |
| `analyze_file` | `path` | High-level summary of every symbol: classes (with dataclass/enum/Protocol/TypedDict classification), functions, async functions, methods and module-level assignments. |
| `list_functions` | `path` | All functions and methods with full signatures (annotations, defaults, return type, decorators, async flag) and line ranges. |
| `get_function_body` | `path`, `name` | Full numbered source of a function or method. Supports `Class.method` and dotted nested paths; falls back to base classes in the same file. |
| `list_methods` | `path`, `type` | All methods of a class: declared, properties, class/static methods, class attributes, and members inherited from base classes defined in the same file. |
| `get_type_definition` | `path`, `name` | Extract a class / `TypeAlias` / `Enum` / `Protocol` / `TypedDict` / `NamedTuple` definition with its members and source. |
| `list_declarations` | `path` | Module-level assignments with annotated or inferred types. |
| `list_exports` | `path` | Public API: respects `__all__` when present, otherwise non-underscore module-level names; flags re-exported imports and names in `__all__` that are not defined. |
| `list_imports` | `path` | All imports with bound name, module path, relative-import level and aliases, grouped into stdlib / third-party / relative. |
| `find_usages` | `path`, `identifier`, `context` (default `1`) | Every occurrence with surrounding source lines: reads, assignments, parameters, attribute access, imports, `global`/`nonlocal`. Adds project-wide references when `jedi` is installed. |
### Call analysis
| Tool | Parameters | Description |
| --- | --- | --- |
| `call_graph` | `path`, `function`, `direction` (`TD`/`TB`/`LR`/`RL`/`BT`, default `TD`), `include_external` (default `false`), `scope` (`file`/`package`, default `file`) | Mermaid flowchart of call relationships. `function` roots the graph at one function; `scope="package"` walks every `.py` file in the containing directory. Also lists edges in text form and functions with no edges. |
| `get_callers` | `path`, `function`, `scope` (`file`/`package`, default `file`) | Reverse call graph: direct callers with call sites, transitive callers, and the entry points that reach the function. |
### Quality
| Tool | Parameters | Description |
| --- | --- | --- |
| `code_complexity` | `path`, `function` | Cyclomatic complexity per function with an A–F rank. Counts `if`/`elif`, `for`, `while`, `for`/`try` `else`, `except`, comprehensions and their `if` clauses, boolean operators, ternaries and `match` cases other than the irrefutable one (`case _:`, or a bare capture — it is the fall-through, not a branch). `with` and `assert` are not counted (neither branches), and nested `def`s are scored separately rather than folded into the parent — so scores match `radon cc --no-assert` exactly. Pass `function` for a decision-point breakdown. |
| `code_smells` | `path`, `function` | Long functions, deep nesting, god classes, too many parameters, mutable default arguments, mutable `@dataclass` field defaults, bare `except:`, shadowed builtins, high complexity. Grouped by severity with a suggested fix. |
| `find_errors` | `path`, `function` | Python-specific hazards: bare/broad `except`, `except: pass`, `except` clauses made unreachable by an earlier one, mutable default args, mutable `@dataclass` field defaults (an import-time `ValueError`), unawaited coroutine calls (best effort), `assert` used for runtime validation, late-binding closures over a loop *or comprehension* variable, `==`/`!=` against `None`/`True`/`False`, `is` against a literal (mirrors CPython's own `SyntaxWarning`), methods that never use `self`. |
| `dead_code` | `path`, `include_tests` (default `false`) | Unreferenced private and module-level symbols across a directory. If `path` is a file, its containing directory is scanned so cross-file references are seen. |
| `find_implementations` | `path`, `interface` | Classes implementing a Protocol/ABC — explicit (direct or indirect base class) and structural (method-set match), plus near misses. The argument is `interface`, not `protocol`, so the same call works against `ts-ast-mcp`. |
### Docs & multi-file
| Tool | Parameters | Description |
| --- | --- | --- |
| `get_doc` | `path`, `name` | Docstring extraction for a function, `Class.method`, class, module (`name="module"`) or documented module constant. Google and NumPy styles are parsed into summary / description / params / returns / yields / raises. |
| `analyze_package` | `path`, `include_tests` (default `false`) | Directory-level summary of every `.py` file: line counts, class/function counts, docstrings, per-file symbol lists, and any unparseable files. |
| `diff_ast` | `old_path`, `new_path` | Structural diff: added / removed / modified functions, methods, classes, base classes, decorators, module variables, imports and `__all__`. Signature-level, with a "potentially breaking" summary. |
| `find_node_at_position` | `path`, `line` (1-based), `column` (0-based) | The AST node at a cursor position, the full node chain, and the enclosing scope chain. Adds jedi-resolved definitions when available. |
## Configuration
### Claude Code
Add a `.mcp.json` at the root of your project (this file is picked up automatically):
```json
{
"mcpServers": {
"py-ast": {
"command": "py-ast-mcp",
"args": []
}
}
}
```
If you installed into a virtualenv, point at it explicitly so the server does not depend on your shell's `PATH`:
```json
{
"mcpServers": {
"py-ast": {
"command": "/absolute/path/to/venv/bin/python",
"args": ["-m", "py_ast_mcp"]
}
}
}
```
Or run it straight from a checkout without installing, using `uv`:
```json
{
"mcpServers": {
"py-ast": {
"command": "uvx",
"args": ["--from", "/absolute/path/to/py-ast-mcp", "py-ast-mcp"]
}
}
}
```
You can also register it from the CLI:
```bash
claude mcp add py-ast -- py-ast-mcp
```
### Claude Desktop
Edit `claude_desktop_config.json`:
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- Linux: `~/.config/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"py-ast": {
"command": "py-ast-mcp",
"args": []
}
}
}
```
Because Claude Desktop does not inherit your shell environment, an absolute path is usually safer:
```json
{
"mcpServers": {
"py-ast": {
"command": "/absolute/path/to/venv/bin/py-ast-mcp",
"args": []
}
}
}
```
Restart Claude Desktop after editing the file.
### Straight from GitHub
No checkout, no install - `uv` fetches and runs it:
```json
{
"mcpServers": {
"py-ast": {
"command": "uvx",
"args": ["--from", "git+https://github.com/gclluch/py-ast-mcp", "py-ast-mcp"]
}
}
}
```
Add `--with jedi` to the args for cross-file semantic resolution.
## Example output
`call_graph` on a small package:
```
# call_graph (package) samplepkg/core.py [16 functions, 11 edges]
## mermaid
```mermaid
flowchart TD
n2_core_Engine_store["core.Engine.store L57"]
n9_util_normalize["util.normalize L4"]
n2_core_Engine_store --> n9_util_normalize
```
## edges
- Engine.store -> validate @L58
- Engine.store -> normalize @L59
- run_pipeline -> Engine.store @L70
```
`code_complexity` on the standard library's `argparse`:
```
# complexity /usr/lib/python3.11/argparse.py [138 functions, module total 350]
average 3.7 max 46 functions over 10: 10
function lines cc rank len depth
------------------------------------ ---------- -- ---- --- -----
ArgumentParser._parse_known_args L1930-2187 46 F 258 6
HelpFormatter._format_actions_usage L406-519 28 D 114 5
```
## Development
```bash
pip install -e ".[dev]"
# unit tests
pytest
# end-to-end: spawn the real server and drive it over stdio with a
# handwritten JSON-RPC client (initialize -> tools/list -> tools/call)
python scripts/stdio_smoke_test.py
```
Layout:
```
src/py_ast_mcp/
server.py MCP tool registration (FastMCP, stdio transport)
parse.py shared parse + cache by path/mtime/size, AST navigation
format.py shared output formatting
analyze.py analyze_file, analyze_package, find_node_at_position
functions.py list_functions, get_function_body, list_methods
types.py get_type_definition, list_declarations
imports.py list_imports, list_exports
usages.py find_usages
callgraph.py call_graph, get_callers
complexity.py code_complexity
smells.py code_smells
errors.py find_errors
deadcode.py dead_code
protocols.py find_implementations
doc.py get_doc
diff.py diff_ast
jedi_support.py optional cross-file resolution
```
## Known limitations
These are heuristics over a syntax tree, not a type checker:
- **The server parses with its own interpreter's grammar.** `ast` can only read syntax the running Python understands, so a server on 3.11 reports `PEP 695` code (`type X = ...`, `def f[T]()`) as a syntax error even though the file is valid. **Run the server on the newest Python you have**, regardless of what the target project targets — parse errors say so when the interpreter may be the cause.
- **Call resolution is name-based.** `obj.method()` is matched by method name; when several classes in scope define the same name the first one wins. `self.method()` resolves within the enclosing class and then across classes in the file.
- **Cross-module call edges** in `scope="package"` are resolved through `import` statements only. Dynamic dispatch, factories and callbacks are not followed.
- **`dead_code` matches by name, not by scope.** `getattr`, plugin registries, entry points and re-exports from outside the scan produce false positives; public symbols are reported separately as lower confidence. The larger risk is the other direction: any attribute access or string literal sharing a symbol's name marks it live, so the tool **under-reports**. Treat hits as candidates to confirm.
- **`jedi` resolution is scoped to the detected project root**, found by walking up for `.git` / `setup.py` / `requirements.txt`. Files outside that root are invisible to `find_usages`' cross-file section.
- **`unawaited-coroutine` is best effort.** It flags calls to `async def` functions declared in the same file that are neither awaited nor wrapped in a recognised `asyncio` helper.
- **Inherited members** are only resolved for base classes defined in the same file; bases from other modules are listed as unresolved.
- **`list_declarations` type inference is literal-shaped**, not a real inference engine: it reports what a reader would infer from the right-hand side.
## License
MIT
TDQS
Scored across 20 tools
Most tools have clearly distinct purposes, but some overlap exists: `analyze_file` vs `list_functions` vs `list_declarations` all surface symbol information, and `code_smells` vs `find_errors` both detect code issues. Descriptions are detailed enough to disambiguate, but an agent might occasionally select the wrong one.
All tool names are snake_case and follow a consistent verb-first pattern (list_*, get_*, find_*, analyze_*). A few noun-phrase names like `code_smells`, `call_graph`, and `dead_code` are exceptions, but they are still readable and fit the overall style without introducing inconsistency.
With 20 tools, the server is on the heavier side of the ideal range but stays within reason for a Python AST analysis domain. Each tool covers a distinct aspect (symbols, calls, complexity, errors, docstrings, diff), so no tool feels redundant; however, the count is slightly above the sweet spot.
The tool surface is comprehensive for static Python analysis: it covers declarations, exports, imports, functions, methods, types, usages, call graphs, complexity, error detection, dead code, implementations, docstrings, package summaries, AST diffs, and node lookup. There are no obvious dead ends for typical analysis workflows.