AST Code Editor MCP Server
A robust, language-agnostic Model Context Protocol (MCP) server that provides AI coding agents with the ability to edit files surgically via Abstract Syntax Trees (AST) instead of relying on token-heavy, brittle search-and-replace or diff operations.
Why AST Edits?
Every non-AST edit format — search/replace, unified diff, whole-file rewrite — requires the model to copy text perfectly from a file it saw once. One whitespace mismatch on a 4,000-line file and the edit fails. AST edits sidestep the problem entirely: the model names the target (e.g., LRUCache.get) and provides the new code; the parser figures out where it lives.
Geometric AGI benchmarked every major format across 4 models and 29 edit tasks. AST edits were the only format to hit 100% correctness on 3 of 4 models, with 18x fewer output tokens than whole-file rewrite, and zero format failures. Full methodology and results: AST Edits: The Code Editing Format Nobody Uses.
Credits
This MCP server was inspired by research from Jack Foxabbott and the team at Geometric AGI. Their full findings, benchmark suite, and data are available here:
- AST Edits: The Code Editing Format Nobody Uses (Blog)
- Jack Foxabbott's original post (LinkedIn)
- GeometricAGI/blog (Benchmark code & data)
Estimated Token Savings
Per-edit output token savings versus other common edit formats:
| Edit size | File size | vs whole-file rewrite | vs unified diff | vs search/replace |
|---|---|---|---|---|
| 1-line tweak | 100 LoC | 3–5x | ~1.5x | ~1.5x |
| Function body rewrite | 500 LoC | 8–12x | 2–3x | 2–3x |
| Function body rewrite | 4,000 LoC | 15–20x | 3–5x | 3–5x |
| Add 2 lines to a function | any size | ~20x (via prepend_to_body / append_to_body) |
5–10x | 3–5x |
Per-read input token savings versus reading the entire file:
| Read task | File size | AST reader tool | vs full file read |
|---|---|---|---|
| One function's source | 500 LoC | read_symbol |
~20x fewer tokens |
| One function's source | 2,000 LoC | read_symbol |
~50-100x fewer tokens |
| Class API (10 methods, no bodies) | 500 LoC | read_interface |
~10x fewer tokens |
| Import block only | any size | read_imports |
~20-50x fewer tokens |
| Structural overview (names + line numbers) | any size | list_symbols |
~15-30x fewer tokens |
| One function's signature | any size | get_signature |
~50-200x fewer tokens |
For daily agent users, a realistic 40-60% reduction in total tokens per session is achievable, on average (combining output savings from surgical edits with input savings from targeted reads).
The savings come from four compounding effects:
- Output tokens: Using
prepend_to_body/append_to_bodyfor small additions instead of rewriting whole function bodies - Input tokens: Using
read_symbol/read_interface/read_importsto read only what's needed instead of entire files (~10-20x fewer input tokens per read) - Discovery:
list_symbols/get_signatureinstead of reading whole files - Zero format failures: AST edits never fail on whitespace drift, eliminating retry loops that plague other formats.
Supported Languages & Capabilities
| Language | Extensions | Structural edits | Comments | Docstrings | Notes |
|---|---|---|---|---|---|
| Python | .py |
✅ | ✅ # |
✅ function/class | Decorators preserved. Module-level dict/list literals editable via add_key / append_to_array. |
| JavaScript | .js, .jsx, .mjs, .cjs |
✅ | ✅ // + /* ... */ |
— | |
| TypeScript | .ts, .tsx |
✅ | ✅ // + /* ... */ |
— | Interfaces are treated as classes for add_method / add_field. |
| C | .c, .h |
✅ | ✅ // + /* ... */ (single + multi-line) |
— | .h defaults to C — use .hpp/.hxx/.hh for C++ headers. |
| C++ | .cpp, .cc, .cxx, .hpp, .hxx, .hh |
✅ | ✅ // + /* ... */ (single + multi-line) |
— | Supports class, struct, union, enum, namespace; Class::method qualified names resolve correctly. |
| Ruby | .rb |
✅ | ✅ # |
— | Classes, modules, instance methods, singleton_method (class methods via def self.foo). require/require_relative/load/autoload recognized as imports. |
| Go | .go |
✅ | ✅ // + /* ... */ |
— | struct, interface, functions, methods. Methods are addressed by receiver: Cache.Get resolves to the top-level func (c *Cache) Get(...). Grouped import (...) blocks supported. |
| Java | .java |
✅ | ✅ // + /* ... */ + Javadoc /** ... */ |
— | class, interface, enum, record; methods, constructors, fields. Annotations (@Override, @Deprecated) travel with their method on edits (wrapped in the modifiers node). Enum methods (nested in enum_body_declarations) discovered via BFS in list_symbols. |
| JSON | .json |
✅ (keys, values, arrays) | — (no comment syntax) | — | |
| YAML | .yml, .yaml |
✅ (keys, values, sequences) | ✅ # |
— | Block and flow sequences supported. |
| TOML | .toml |
✅ (keys, values, arrays, tables) | ✅ # |
— | [table] headers addressable by name for comment tools. |
Cross-cutting features:
- Decorated functions (Python
@decorator): decorators are preserved on body/signature edits and included on delete. - Byte-correct slicing: multi-byte characters (emoji,
═,→) handled safely in source text. - Idempotent imports:
add_importskips exact duplicates automatically. For Go specifically, when a parenthesizedimport ( ... )block already exists, new specs are inserted inside the block rather than as a bare top-level line (which would be a syntax error for spec-only input like"path/filepath"). - Doc-comment-aware deletion:
delete_symbolby default removes the contiguous leading comment block above the symbol (Godoc, Javadoc,#///comment runs) so docs don't become orphaned. Opt out withinclude_leading_comments=False.
Language-specific design decisions
A few tools have language-specific semantics where multiple reasonable interpretations exist. The chosen behavior is documented here for transparency:
add_field (Ruby and Go) — option (a): literal text passthrough
- Ruby:
add_field("LRUCache", " attr_accessor :capacity")inserts the literal string at the top of the class body. The tool does not auto-wrap bare names inattr_accessor— you provide the exact text you want (whether that'sattr_accessor,attr_reader,@instance_var = nilininitialize, orCLASS_CONST = 42). - Go:
add_field("Cache", "\tversion int")inserts the literal string inside thestruct { ... }body. The tool does not infer types from bare names — you provide the full Go field declaration. - Rationale: consistent with how
add_fieldworks for other languages (Python, JS/TS, C++) where the caller provides the full source text. The alternative option (b) — auto-wrapping (e.g.attr_accessor :foofrom the namefoo) — would be more magical but harder to use for edge cases (typed fields, readonly fields, field with default value, etc.).
add_method (Go) — option (a): top-level sibling insertion
add_method("Cache", "func (c *Cache) Has(key string) bool { ... }")locates thetype Cache struct { ... }declaration and inserts the new method immediately after it, at the top level (not inside the struct's braces).- Rationale: Go methods are lexically top-level, not nested inside their receiver type — this matches how Go code is actually written. The alternative option (b) — refusing because "Go methods aren't inside structs" — would be pedantically correct but force callers to use
insert_after("Cache", content)instead, which loses the semantic signal that this is a method addition.
Tools Exposed
All tools require file_path to be an absolute path to an existing file.
Code editing — structural (Python, JS, TS, C, C++, Ruby, Go, Java)
| Tool | Parameters | Description |
|---|---|---|
replace_function |
file_path, target, content |
Replace a full function definition (signature + body + decorators). |
replace_function_body |
file_path, target, content |
Replace only the body of a function, preserving signature and decorators. |
replace_signature |
file_path, target, new_signature |
Replace only the signature, preserving body and decorators. |
replace_in_body |
file_path, target, old_snippet, new_snippet |
Replace a byte-identical snippet inside a function body. Scoped to target's body; raises on multiple matches. |
delete_in_body |
file_path, target, snippet |
Delete a byte-identical snippet inside a function body. Scoped to target's body; raises on multiple matches. |
insert_in_body |
file_path, target, new_snippet, at | after | before |
Insert a snippet inside a function body. Pass exactly ONE of: at="top" (prepend), at="bottom" (append), after=<snippet> (anchored), before=<snippet> (anchored). |
add_top_level |
file_path, content, position="bottom" |
Insert top-level content. position="bottom" appends at end of file (default); position="top" inserts after preamble (package/imports/includes/leading comments, plus Python module docstring) and before the first real declaration. |
add_method |
file_path, class_target, content |
Add a method at the end of a class body. |
add_field |
file_path, class_target, content |
Add a field/attribute/member at the top of a class body. |
insert_sibling |
file_path, target, content, position |
Insert content as a sibling of a named symbol. position="before" or "after". |
delete_symbol |
file_path, target, include_leading_comments=True |
Delete a function or class definition block (including decorators). By default also consumes the contiguous leading comment block above the symbol (Godoc, Javadoc /** ... */, # or // comments); pass include_leading_comments=False to keep it. |
Parameters & signatures
| Tool | Parameters | Description |
|---|---|---|
add_parameter |
file_path, target, parameter, position |
Add a parameter to a function signature (position: "start" or "end"). |
remove_parameter |
file_path, target, parameter_name |
Remove a parameter by name. |
Imports & includes
| Tool | Parameters | Description |
|---|---|---|
add_import |
file_path, import_text |
Add an import/from/#include line. Skips duplicates. For Go, if a parenthesized import ( ... ) block already exists, the spec is inserted inside that block (accepts either import "foo" or just "foo" / alias "foo" as input). |
remove_import |
file_path, import_text |
Remove a matching import line. |
add_import_name |
file_path, module, name |
Add one name to an existing named-import statement: from <module> import a, b (Python) or import { a, b } from "<module>" (JS/TS). Idempotent. |
remove_import_name |
file_path, module, name |
Remove one name from a multi-name named-import statement (Python and JS/TS). If the last named import is removed and no default/namespace binding remains, the whole line is removed. |
Comments & docstrings
| Tool | Parameters | Description |
|---|---|---|
edit_leading_comment |
file_path, target, op, comment="" |
Edit the contiguous leading-comment block above a named symbol. op="add" inserts; op="replace" replaces (or inserts if none); op="remove" deletes. Works for # / // / /* ... */ / Javadoc /** ... */. |
replace_docstring |
file_path, target, new_docstring |
Replace or insert a Python function/class docstring. Python-only. |
Dict/list editing (JSON, YAML, TOML, AND Python module-level dict/list literals)
| Tool | Parameters | Description |
|---|---|---|
replace_value |
file_path, target, content |
Replace the value of an existing config key. target is the dotted key path. |
add_key |
file_path, parent_target, key, value |
Add a key-value pair to a dict/object/mapping/table. For Python, parent_target is the dict variable name; for config, a dotted path (use "" for root). |
delete_key |
file_path, target |
Delete a key-value pair. Targets: JSON/YAML/TOML dotted path; Python DictName.keyExpr; JS/TS VarName.keyName on const/let/var or export const object literals (handles regular pairs, { key } shorthand, and quoted "complex-key"). For JSON and JS/TS, adjacent comma is also removed. |
append_to_array |
file_path, target, value |
Append a literal value to a list/array/sequence. For Python, target is the list variable name; for config, a dotted path. |
remove_from_array |
file_path, target, value_match |
Remove the first matching element from a list/array/sequence. |
Navigation & reading (read-only)
| Tool | Parameters | Description |
|---|---|---|
list_symbols |
file_path |
Formatted outline of all top-level functions, classes, and methods with line numbers. |
find_references |
file_path, target |
Syntactic search for all occurrences of an identifier (no scope awareness). |
read_symbol |
file_path, target, depth="full" |
Return source text of a single named symbol. depth controls how much: "full" returns the entire source (typically 10-20x fewer tokens than the whole file); "interface" returns a class stub (header + fields + method sigs with ...) or a function's signature; "signature" returns signature-only. |
read_imports |
file_path |
Return all import/include statements in the file. |
Target format: Use the exact function name (e.g., get) or dotted Class.method path (e.g., LRUCache.get). Decorated Python functions are fully supported — decorators are preserved when replacing bodies or signatures, and included when deleting or replacing the full function.
Tip: Call list_symbols first to discover exact target names before editing. This avoids guessing and makes subsequent edits much more reliable.
Which tool should I use?
A decision guide grouped by intent. Start at the top and pick the narrowest match.
Discovering what's in a file (do this first)
- Don't know what symbols exist? →
list_symbols - Need one specific function's full source? →
read_symbol(depth="full", the default — 10-20x cheaper than reading the whole file) - Need a class's public API (methods + fields, no bodies)? →
read_symbol(target, depth="interface") - Need just a function's signature? →
read_symbol(target, depth="signature") - Need to see a file's imports/dependencies? →
read_imports - Where is a symbol used? →
find_references
Dotted targets descend into closures: Go stdioCmd.RunE (func_literal in struct field), TS app.handler (arrow function in object literal).
Adding new content
| Intent | Tool |
|---|---|
| New top-level function, class, constant, or type alias | add_top_level (use position="top" to prepend after preamble) |
| New method in an existing class | add_method |
| New field/attribute/member in a class | add_field |
| New content before or after a top-level symbol | insert_sibling(position="before" | "after") |
| New lines at the top of an existing function body | insert_in_body(at="top") |
| New lines at the bottom of an existing function body | insert_in_body(at="bottom") |
| New lines at a specific spot inside a function body (anchored to existing text) | insert_in_body(after=…) or insert_in_body(before=…) |
| New parameter on an existing function | add_parameter |
New import or #include |
add_import |
New name in an existing from X import … or import { a, b } from "mod" |
add_import_name |
| New comment above a symbol | edit_leading_comment(op="add") |
| New Python docstring on a function/class | replace_docstring |
| New key in a dict/object/mapping/table (any lang) | add_key |
| New item in a list/array/sequence (any lang) | append_to_array |
Modifying existing content
| Intent | Tool |
|---|---|
| Rewrite the full function (signature + body) | replace_function |
| Rewrite only the body, keep the signature | replace_function_body |
| Change one statement/block inside a large body | replace_in_body (scoped snippet match) |
| Change only the signature, keep the body | replace_signature |
| Change only the leading comment above a symbol | edit_leading_comment(op="replace") |
| Change only the Python docstring | replace_docstring |
| Change the value of an existing config key | replace_value |
Removing content
| Intent | Tool |
|---|---|
| Remove a function, method, or class | delete_symbol (consumes leading doc comment by default) |
| Remove one statement/line inside a function body | delete_in_body |
| Remove a parameter from a function | remove_parameter |
Remove an import or #include |
remove_import |
| Remove one name from a multi-name named-import (Python or JS/TS) | remove_import_name |
| Remove a leading comment above a symbol | edit_leading_comment(op="remove") |
| Remove a key from a dict / config / JS-TS object literal | delete_key |
| Remove an item from a list/array | remove_from_array |
Anti-patterns to avoid
- Don't use
replace_functionorreplace_function_bodyto change a few lines — usereplace_in_body(scoped snippet match) orinsert_in_body(at="top" \| "bottom")for appending/prepending. Rewriting the whole function is wasteful and error-prone. - Don't use
replace_signatureto add or remove one parameter — useadd_parameter/remove_parameter. - Don't use
replace_valueto add a new key — useadd_key.replace_valueonly updates existing keys. - Don't use
add_importto add a name to an existingfrom X import …or named import — useadd_import_name. - Don't guess at target names. Call
list_symbolsfirst. Names are case-sensitive and must match exactly.
Logging & Debugging
The server logs all tool invocations and errors to stderr (safe for stdio transport — does not interfere with JSON-RPC). Logs include timestamps and severity levels.
To inspect logs when running under Claude Desktop, check ~/Library/Logs/Claude/mcp*.log (macOS) or %APPDATA%\Claude\logs\mcp*.log (Windows).
For interactive testing, use the MCP Inspector.
Prerequisites
This MCP server uses uv to manage its Python environment and dependencies automatically. Install uv if you don't have it already:
macOS / Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Homebrew (macOS):
brew install uv
pip (any platform):
pip install uv
Verify the installation:
uv --version
For more options (Docker, Cargo, WinGet, etc.), see the official uv installation docs.
Installation
Note: Replace
/absolute/path/tobelow with the actual path to this repository on your machine.
Method 1: CLI Configuration (Claude Code, Codex, Gemini)
If your agent supports adding servers via CLI, run the following:
Claude Code / Codex CLI / Gemini CLI:
--scope user installs the server globally so it's available in every project on your machine. Drop it if you only want the server active in the current project.
# Claude Code / Codex
[claude|codex] mcp add ast-editor --scope user -- uv --directory /absolute/path/to/ast-editor run ast-editor-mcp
# Gemini CLI
gemini mcp add --transport stdio --scope user ast-editor -- uv --directory /absolute/path/to/ast-editor run ast-editor-mcp
Method 2: JSON Configuration
For tools that use a mcp_config.json or settings.json file, add the following block to the appropriate file path.
Important: Use the absolute path to
uvfor"command", not just"uv". GUI-based MCP clients (Claude Desktop, Cursor, Antigravity) don't always inherit your shellPATH, so a bare"uv"will fail with a "command not found" error. Get your absolute path with:which uv # e.g. /Users/you/.local/bin/uv or /opt/homebrew/bin/uv
{
"mcpServers": {
"ast-editor": {
"command": "/absolute/path/to/uv",
"args": [
"--directory",
"/absolute/path/to/ast-editor",
"run",
"ast-editor-mcp"
]
}
}
}
| Agent | Configuration File Path |
|---|---|
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json (macOS); %APPDATA%\Claude\claude_desktop_config.json (Windows) |
| Cursor | .cursor/mcp.json (Project) or ~/.cursor/mcp.json (Global) |
| Windsurf | Agent Panel → "..." → MCP Servers → View raw config |
| Antigravity | ~/.gemini/antigravity/mcp_config.json (or via Agent Panel) |
| Gemini CLI | ~/.gemini/settings.json (Global) or .gemini/settings.json (Project) |
Using Standard Python (Fallback)
If you prefer not to use uv, install manually and point to the .venv executable in ast-editor directory:
python3 -m venv .venv && source .venv/bin/activate && pip install .
{
"mcpServers": {
"ast-editor": {
"command": "/absolute/path/to/ast-editor/.venv/bin/python",
"args": ["-m", "ast_editor.server"]
}
}
}
Agent Configuration (Important)
Coding agents are heavily biased toward their default tools. You must explicitly instruct them to use AST tools. The agent prompt lives in AST-EDITOR.md — a standalone file you wire into your agent's system instructions.
Claude Code / Claude Desktop (via @-include)
Claude Code supports @filename includes in CLAUDE.md. Copy the prompt file into your global config directory and add one include line:
cp /absolute/path/to/ast-editor/AST-EDITOR.md ~/.claude/
echo '@AST-EDITOR.md' >> ~/.claude/CLAUDE.md
Or for a single project, place it next to the project's CLAUDE.md and add @AST-EDITOR.md there.
Other agents (Cursor, Codex CLI, Windsurf, Antigravity, Aider, Gemini CLI, etc.)
Copy the contents of AST-EDITOR.md into your agent's instruction file (AGENTS.md, .cursor/rules/*.mdc, .windsurfrules, .github/copilot-instructions.md, system prompt, etc.). Most non-Claude agents don't support @-include — paste the prompt body directly.
| Agent | Instruction file |
|---|---|
Any agent that reads AGENTS.md (Codex CLI, Windsurf, Zed, Cursor secondary) |
AGENTS.md at repo root |
| Cursor | .cursor/rules/*.mdc (current) — legacy: .cursorrules |
| GitHub Copilot | .github/copilot-instructions.md |
| Windsurf | .windsurfrules or AGENTS.md |
| Antigravity | _agents/rules/ |
| Aider / Gemini CLI / generic | Rules file or system prompt |
Migrating from v1.x / pre-AST-EDITOR.md instructions
If your CLAUDE.md (or other rules file) contains an inline quoted "When editing ... use ast-editor" block from an older README version, delete that block and replace it with the @AST-EDITOR.md include (or paste the current file's contents). The old block will reference tool names removed in v2.0.0 consolidation (prepend_to_body, append_to_body, insert_before, insert_after, add_comment_before, replace_leading_comment, remove_leading_comment, read_interface, get_signature) — keeping it will cause agents to call tools that no longer exist.
v2.0.0 consolidated 10 closely-related tools into 4 parametrized tools. Mapping:
| v1.x tool | v2.0.0 equivalent |
|---|---|
add_comment_before(target, comment) |
edit_leading_comment(target, op="add", comment=...) |
replace_leading_comment(target, new_comment) |
edit_leading_comment(target, op="replace", comment=...) |
remove_leading_comment(target) |
edit_leading_comment(target, op="remove") |
read_symbol(target) |
read_symbol(target) (or explicit depth="full") |
read_interface(target) |
read_symbol(target, depth="interface") |
get_signature(target) |
read_symbol(target, depth="signature") |
prepend_to_body(target, content) |
insert_in_body(target, content, at="top") |
append_to_body(target, content) |
insert_in_body(target, content, at="bottom") |
insert_before(target, content) |
insert_sibling(target, content, position="before") |
insert_after(target, content) |
insert_sibling(target, content, position="after") |
The old tools are hard-removed — calling them will fail with "unknown tool". Behavior is preserved 1:1 by the new calls.