ast-editor
Use ast-editor to surgically read, navigate, and edit source and config files via AST-aware tools across many languages.
Discover and read code: list symbols, read full symbols or signatures/interfaces, read imports, and find references.
Edit functions: replace whole functions, bodies, or signatures; replace/delete/insert snippets inside bodies.
Add/remove structure: add top-level symbols, methods, fields, sibling symbols; delete functions/classes with optional leading comments.
Manage signatures: add or remove parameters without rewriting the whole signature.
Manage imports: add/remove import lines and add/remove names in multi-name imports.
Edit comments and docstrings: add/replace/remove leading comments and replace Python docstrings.
Edit config/data: update/add/delete keys and append/remove array items in JSON, YAML, TOML, Python dict/list literals, and JS/TS object literals.
Stay safe: syntax-checked saves, layout-aware indentation, and preservation of line endings and BOMs.
Supported languages: Python, JavaScript, TypeScript, C, C++, Ruby, Go, Java, JSON, YAML, and TOML.
Provides AST-based structural editing for C++ source files, supporting classes, structs, unions, enums, namespaces, methods, fields, imports, comments, and symbol reads/edits.
Provides AST-based structural editing for JavaScript files, including functions, classes, imports, comments, and targeted body/signature replacements.
Provides AST-based structural editing for Python files, including functions, classes, methods, imports, decorators, docstrings, comments, and module-level dict/list literals.
Provides AST-based structural editing for Ruby files, supporting classes, modules, instance/class methods, fields, imports, comments, and symbol reads/edits.
Provides AST-based editing for TOML files, including keys, values, arrays, and tables, with comment support.
Provides AST-based structural editing for TypeScript files, including functions, classes/interfaces, imports, comments, and targeted body/signature replacements.
Provides AST-based editing for YAML files, including keys, values, sequences, and comment support.
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:
Jack Foxabbott's original post (LinkedIn)
GeometricAGI/blog (Benchmark code & data)
Related MCP server: ast-grep MCP Server
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 | 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 |
| ~20x fewer tokens |
One function's source | 2,000 LoC |
| ~50-100x fewer tokens |
Class API (10 methods, no bodies) | 500 LoC |
| ~10x fewer tokens |
Import block only | any size |
| ~20-50x fewer tokens |
Structural overview (names + line numbers) | any size |
| ~15-30x fewer tokens |
One function's signature | any size |
| ~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
insert_in_body(at="top"/at="bottom", or anchored withafter=/before=) for small additions instead of rewriting whole function bodiesInput tokens: Using
read_symbol(depth="full"/"interface") /read_importsto read only what's needed instead of entire files (~10-20x fewer input tokens per read)Discovery:
list_symbols/read_symbol(depth="signature")instead of reading whole filesZero 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 |
| ✅ | ✅ | ✅ function/class | Decorators preserved. Nested classes (decorated ones too) are listed with their members ( |
JavaScript |
| ✅ | ✅ | — | Exported declarations ( |
TypeScript |
| ✅ | ✅ | — | Exports, generators and functions bound to names as for JavaScript. Abstract classes ( |
C |
| ✅ | ✅ | — |
|
C++ |
| ✅ | ✅ | — | Supports |
Ruby |
| ✅ | ✅ | — | Classes, modules, instance methods, |
Go |
| ✅ | ✅ | — |
|
Java |
| ✅ | ✅ | — |
|
JSON |
| ✅ (keys, values, arrays) | — (no comment syntax) | — | Numbers with a |
YAML |
| ✅ (keys, values, sequences) | ✅ | — | Block and flow sequences supported. Keys inside flow mappings are addressable by path ( |
TOML |
| ✅ (keys, values, arrays, tables) | ✅ | — |
|
Cross-cutting features:
Decorated functions and classes (Python and JS/TS
@decorator, Java annotations): decorators are part of the symbol. They are preserved on body edits;read_symbol,replace_functionanddelete_symbolinclude them; a leading comment belongs above the first of them (edit_leading_commentputs it there and finds an existing one there), andinsert_sibling(position="before")goes above them and that comment, so the comment stays on the symbol it documents. This holds for TypeScript methods too, whose decorators the grammar places beside the method in the class body, for decorated Python classes, for a C++ template'stemplate <...>header and for a Ruby Sorbetsig.replace_signaturekeeps them as written unless the new signature includes its own, which then replace them.Layout-aware
add_method/add_field: the new member is indented like the class's existing members (falling back to the file's indent unit, then the language default: 4 spaces for Python and Java, a tab for Go, 2 spaces otherwise), keeping the content's relative indentation; in a class indented with tabs that indentation is written in tabs too (one per indentation step of the content), so a snippet written with spaces fits a tab-indented file. The lines that start inside a multi-line string literal of the content (a triple-quoted string, template literal, heredoc, raw string, C string continued with\) or a Ruby embedded document (=begin...=end, at column 0) keep their text, here and in every tool that re-indents content (replace_function,replace_function_body,insert_in_body(at=...),insert_sibling,edit_leading_comment); a Python docstring, a Ruby<<~heredoc and a Java text block, whose indentation the language strips, move with the code instead, every line by the same amount. A method follows the last member after one blank line, or none when the class's methods have no blank lines between them (a C++ member template counts as a method; TS interface members stay one per line) or the last member is a C++ access label (public:), and one-line content after stacked one-line members (fields) is stacked with them. In a C++ class whose last method is followed by an access label (public:,protected:,private:), the new method goes right after that method, spaced as methods are, so it gets the same access; a comment after that method on its line stays with it, a method inside an#if/#ifdefblock (either branch) counts as the last method (the new one goes after the#endif), and a label moved off a method's line is indented like the class's other labels, or at the class's indentation. A C++ class with no label after its last method, or with no methods, gets the new method after its last member. Content inread_symbol's shape copied from another class (its first line without indentation, the later lines at the indentation they had there) is indented as a whole, a Python def without decorators only when it was read at the indentation of this class's members. A field goes before the first member (after a Python class docstring, and below the access label a C++ class body opens with, so it gets that section's access), with a blank line after it when that member is a method (a C++ member template counts), and a comment after the{on its line (class K { // NOLINT) stays there. In a Java enum both go after the constants, and a;is added after them when they have none. No blank line is added before the closing brace, and a braced body on one line (class K {},class K { a() {} }, Gostruct{ A int }) is opened up over several lines, its existing members on a line of their own. A Python or Ruby class written on one line (class K: pass) is refused. Both tools look only at containers, so a name a struct or interface shares with a function (struct statandint stat(...)) is the struct, a typedef of a struct declared apart (typedef struct Node Node;) is that struct, and a function target is refused (useinsert_in_body).Layout-aware
add_key: in JSON objects, Python dicts, JS/TS object literals, YAML flow mappings and TOML inline tables the new entry follows the container's layout (one key per line at the existing indentation, inline objects stay inline, a trailing comma is kept, and a comment on the last entry's line stays there); an empty JS/TS or YAML{}becomes{ key: value }(TOML:{ key = value }). A YAML key with no value gets a nested block mapping one indentation level deeper (inside a flow mapping, an inlineparent: { key: value }). YAML and TOML keys are quoted only when they need it:my keyanda.bstay plain in YAML, whilea:b,yes,123or a leading-/#/&get quotes (in the quote style most of the file uses); in TOML anything but a bare key (letters, digits,_,-) is quoted, and dots separate the parts of a dotted key, sob.caddscunderband a name with a dot in it must be passed quoted ('"example.com"'). A JSON or YAML key passed already in quotes is used as given. A key the container already has is refused with a pointer toreplace_value, for every format, naming a target that works (quoted where the key has a dot, inside flow mappings and inline tables too).Layout-aware
append_to_array: JSON arrays, TOML arrays, YAML flow sequences and Python lists keep their layout: an inline array stays inline ([1, 2]becomes[1, 2, 3],[]becomes[3]), an array with one element per line gets a new line at the elements' indentation (also one aligned under its[whose]closes the last line), a trailing comma is kept (the new element gets one too), and comments are not elements: one on the last element's line stays there, after the comma (2, # two), and a value that ends with a comment gets its comma before it (3, # new). A Python list whose[and]are on lines of their own with nothing between them gets the value on a line of its own, with a comma. A YAML block sequence gets a new- valueitem.Config paths from the top level: a dotted config target is followed from the top of the file, one key per segment:
a.bis keybunder the top-level keya, never ana.bnested deeper (such asx.a.b), and in TOML a plainversionis a top-level key, not one inside a[table](useproject.version). One path syntax covers JSON, YAML and TOML and every config tool (read_symbol,replace_value,add_key'sparent_target,delete_key,append_to_array,remove_from_arrayandedit_leading_comment): a segment in double quotes is one key taken literally, dots included, its escapes resolved as in a JSON string ("editor.fontSize",a."b.c".d), and in TOML single quotes work too (a.'b.c'), as in a TOML dotted key, whose syntax TOML paths share. Keys compare by the names they spell, so"a.b"findsa.b: 1,'a.b': 1and"a.b": 1in YAML, and[x.'y.z']and[ x . "y.z" ]are both TOML tablex."y.z". Keys inside YAML flow mappings and TOML inline tables are part of the path. Elements of arrays are not addressable by path (a.0is a key named0).Byte-correct slicing: multi-byte characters (emoji,
═,→) handled safely in source text.Import placement:
add_importskips exact duplicates. A new import goes into the group it belongs with, a group being a run of imports, includes or requires with no blank line or other code between them (comments may be; a conditional of includes counts as one include, a CommonJSrequireas an import). The group is picked by language. Python: a group holding imports of the same top-level package, else the standard-library group for a standard-library module and the group of other modules for others, relative imports with relative imports. C/C++:<...>with<...>,"..."with"...", never a group of only the file's own header ("foo.h"infoo.cpp). Go: standard-library paths (no dot in the first path element) with standard-library paths, others with others, never with cgo'simport "C". JS/TS: relative paths (./,../) with relative paths, packages with packages, arequirewith requires. Java: static with static. Ruby:require_relativewithrequire_relative. When several groups fit, the one with an import sharing the longest leading path with the new one wins (package parts, directories:from app.y import zgoes withfrom app import x,#include <sys/types.h>with<sys/stat.h>), else the first; when none fits, the import goes after the file's last import. In a group that is already sorted it goes at its sorted place, else after the group's last import. Python sorts as isort and ruff do: module names without case, numbers in numeric order,import xlines beforefrom x importlines in a group that orders them that way, else both together. The other languages sort by the path or module text (C/C++ includes as written, with their brackets or quotes). The import goes above the next import and the comment lines right above it, but below a comment above the group's first import, which heads the group; a comment that applies to the next line stays on its import even when the new import sorts first (@ts-ignore,@ts-expect-error, eslint or tslintdisable-next-line, pylintdisable-next,biome-ignore,deno-lint-ignore,prettier-ignore,NOLINTNEXTLINE, Flow suppressions, istanbul, c8 or v8ignore next, Go//nolintand//lint:ignore,noinspection). The blank lines between groups stay as they are. Pythonfrom __future__imports stay first: a new one goes after the existing ones, or before the file's first import, and other imports go after them; afrom __future__import that its group would place after code goes right after the module docstring, before any other statement, with a blank line on each side. Go: inside animport ( ... )block the same rules apply to the block's blank-line groups of specs, an alias sorting by its path ("io"goes between"fmt"and"os"); a block written on one line (import ("fmt"; "os"),import ()) takes the spec inline at its sorted place; without a block, a spec given without its keyword ("fmt",f "path/filepath") is written asimport "fmt"; and nothing goes betweenimport "C"and its cgo preamble comment (the new import goes afterimport "C"). A file's first import goes after its preamble (a package clause,#!and encoding or magic comments, the module docstring,'use strict'and other directives, TS///lines,#pragma once, an include guard's#define, and a header comment set off by a blank line), with a blank line above it and one below it (two before a Python def or class).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. The block ends at a blank line and at a comment line indented deeper than the symbol, which belongs to the code above it (# end of funder f's last line, a commented-out YAML list item), for this and every tool that finds a leading comment:insert_sibling(position="before")goes below such a comment, andedit_leading_commentneither removes nor replaces it. Such comment lines right below a symbol's last line belong to it:delete_symbolremoves them with it andinsert_sibling(position="after")goes below them (read_symbolandreplace_functionleave them out). A Ruby=begin...=enddocument directly above a symbol is its leading comment. Only real comments count, for this and every other tool that finds a leading comment: a line that starts with#or//inside a multi-line string, template literal or YAML block scalar is text. A#!line and the encoding or magic comments after it (# -*- coding: utf-8 -*-,# frozen_string_literal: true) at the top of a Python or Ruby file are never taken for a symbol's comment, by this or any other tool.Layout-aware deletion: the blank lines that separated a deleted symbol go with it, so the rest keeps the file's spacing: deleting the first symbol of a file or class body leaves no blank lines at the top or after the
{, deleting the last one leaves none before the closing brace (and the next top-level symbol keeps its own separation, such as Python's two blank lines), and between the two symbols that remain only the wider of the two gaps stays. Only empty lines and lines of spaces and tabs are blank: a form feed line (a Python page break) next to the symbol stays. A symbol that shares a line with other code (class A { a() {} b() {} },x = 1; y = 2) is cut out of that line instead, with the;or,that ends it (the last entry of a one-line literal takes the one before it:{ a: 1, b: 2 }minusbis{ a: 1 }) and a comment after it at the end of the line; the spaces that were before it stay between the code on both sides.replace_functionlikewise replaces only such a symbol's own text, and refuses content that ends with a#or//comment when code follows the symbol on its line.Syntax-checked saves: before anything is written, the edited file is re-parsed with its tree-sitter grammar. An edit that would introduce a syntax error into a file that parsed cleanly is refused with the error's line and column (e.g.
Edit refused: the result would have a syntax error at line 11, column 2 (missing '}')), and the file is left unchanged. Files that already had syntax errors are not guarded, so you can keep working on them. For.pyfiles Python's own parser (that of the interpreter running the server) decides: it catches the indentation errors tree-sitter lets through and accepts newer syntax the grammar does not know (PEP 696 type parameter defaults). For.jsonfiles thejsonmodule overrules the grammar wherever it would reject valid JSON, and comments in JSONC files stay allowed; exponents written with a+(1e+2), which tree-sitter-json cannot parse, are masked before parsing, so files that use them are checked like any other. Other languages have no second parser, so valid code using syntax the grammar does not support (very new language features, macro-heavy C/C++) can be refused: if you are sure the code is valid, make that edit with your normal edit tool.Line endings and byte order marks preserved: CRLF files stay CRLF and LF files stay LF, whichever tool makes the edit, and blank lines at the end of a file are kept as they were (deleting the last symbol does not leave extra ones behind); a file whose last line has no line break keeps it that way. A UTF-8 byte order mark stays at the start of the file, whatever the edit adds or removes at the top; it never shows up in tool output, and content that starts the file with one leaves a single mark. Files that are not UTF-8 (UTF-16, UTF-32, Latin-1), also UTF-16 or UTF-32 without a byte order mark and files with NUL bytes, are refused with a message saying so, and left unchanged. Write content, snippets and anchors with
\nline breaks: inserted text is converted to the file's style, and a\nin a snippet or anchor matches a CRLF line break. Content is split into lines on\n(or CRLF) only, so a form feed, vertical tab, U+0085, U+2028 or U+2029 inside a string literal or comment stays where it was written. Only spaces and tabs count as indentation, so when content is re-indented a form feed at the start of a line (a Python page break,\fdef g():) or a U+2028 starting a line inside a string stays, and a form feed before the target's owndefis not copied onto the new lines. In a file that mixes CRLF and LF, every line the edit does not rewrite keeps its own ending (the lines next to the edit too, though they read like lines it removed or added); a line it writes takes the ending of the lines it replaces when they agree, else of the lines on both sides when they agree, else the file's most common one.Body snippets and anchors (
replace_in_body,delete_in_body,insert_in_body(after=…, before=…)): a snippet matches its exact text (\nmatching CRLF) where it neither starts nor ends inside a token of code, an identifier, number, keyword or operator:x = 1is not found inmax = 10, nor= 1ina == 1,elin$elorfooin Rubyfoo?; inside a comment or string (not in an interpolation's code) it may start or end between any two words, and it may end at a Python backslash continuation. Without an exact match, its lines match whole lines of the body with one indentation added to or taken from each of them, so a block copied at column 0 or deeper than in the file is found: blank lines match blank lines, spaces and tabs at line ends do not count, tabs and spaces differ, and a snippet that starts or ends in the middle of a line must match exactly. The text of a multi-line string is never shifted: a line that starts inside one is matched only as written in the file (Python docstrings, Ruby<<~heredocs and Java text blocks, which move with the code, aside). An exact match wins, except that when every exact match is inside a string or comment, shifted matches in other places count too. The match must be unique: two or more places are refused with their line numbers (Snippet matches 2 places in body of 'f' (lines 2, 4)), and text found only inside longer tokens is refused saying so. A deletion that leaves its lines without code takes the whole lines, indentation and line break included; a comment after the deleted code on its last line stays, at the line's indentation; a Python body is never left without a statement (replace the snippet withpass). New text goes in indented as the snippet matched: moved with a shifted match's lines; after an exact one-line snippet that left out its line's indentation (or starts mid-line), its later lines given that indentation unless they are that deep already; after an exact snippet of several lines, or when already at the file's indentation there, as written; in Python, when one reading of the indentation does not parse and the other does, the other is taken.insert_in_bodyputs new text on lines of its own when the anchor ends its line (after) or starts it (before), indented like the anchor (new text at column 0 beside an anchor written with its indentation goes at the anchor's, and text inread_symbol's shape goes in as written), and inside the block that anafteranchor's line opens (if (x) {,if x:); in the middle of a line, or where the new line would start in the text of a string or heredoc (not a docstring,<<~heredoc or Java text block, whose lines are indented like code), it is placed byte for byte. Lines of new text that go into a string of the file keep their text.Tab-indented code: every tool that writes source content over several lines (
replace_function,replace_function_body,replace_in_body,insert_in_body,insert_sibling,add_top_level,add_method,add_field) writes the content's indentation steps in tabs where the file indents with tabs there: the place's indentation has a tab, or, at column 0, most of the indented lines of the code it replaces or goes beside start with one (the text of its strings and lines aligned under a bracket aside), or, with no such lines, the file is Go or mostly tab-indented. Each step of the content's own indentation becomes one tab; the text of multi-line strings, Ruby=begindocuments, and the spaces that align a block comment's lines or a line continued inside a(or[stay as written. Config files are never converted.
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), indented with tabs and with a blank line on each side.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_sibling("Cache", content, position="after")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 a full function definition (signature + body + decorators, and a JS/TS |
|
| Replace only the body of a function, preserving signature and decorators. A body written on one line with its signature or braces ( |
|
| Replace only the signature, preserving the body. |
|
| Replace a snippet inside a function body, scoped to the target's body: its exact text at token boundaries, else its whole lines with their indentation shifted, matching exactly one place (see Body snippets and anchors above). |
|
| Delete a snippet inside a function body, matched as |
|
| Insert a snippet inside a function body. Pass exactly ONE of: |
|
| Insert top-level content. |
|
| Add a method at the end of a class body (in a C++ class, right after the last method when an access label follows it), indented like the class's members and separated as its methods are (see Layout-aware |
|
| Add a field/attribute/member at the top of a class body (after a Python class docstring or a Java enum's constants, below the access label a C++ class body opens with), indented like the class's members. |
|
| Insert content as a sibling of a named symbol. |
|
| Delete a function or class definition block (including decorators, a JS/TS |
Parameters & signatures
Tool | Parameters | Description |
|
| Add a parameter to a function signature ( |
|
| Remove a parameter by name. One on a line of its own goes with that line and the comment at its end; comments on other lines stay (one after the parameter before it too), a last parameter with no comma after it takes the comma before it (except in Go before a line break, which needs it), and removing the only parameter leaves |
Imports & includes
Tool | Parameters | Description |
|
| Add an |
|
| Remove a matching import line, compared after stripping surrounding whitespace: pass the exact line. |
|
| Add one name to an existing named-import statement: |
|
| 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; removing the last name beside a default binding ( |
Comments & docstrings
Tool | Parameters | Description |
|
| Edit the contiguous leading-comment block above a named symbol, which is above its decorators/annotations, a JS/TS |
|
| Replace or insert a Python function/class docstring, or the module's for |
Dict/list editing (JSON, YAML, TOML, Python module-level dict/list literals, AND JS/TS object literals)
Values (content, value) are literal source text in the file's own syntax ('"2.0.0"', 42): quote strings where the format requires it.
Tool | Parameters | Description |
|
| Replace the value of an existing key. Targets as for |
|
| Add a key-value pair to a dict/object/mapping/table. For config, |
|
| Delete a key-value pair. Targets: JSON/YAML/TOML dotted path from the top level ( |
|
| Append a literal value to a list/array/sequence. For Python, |
|
| Remove the first matching element from a list/array/sequence. |
Navigation & reading (read-only)
Tool | Parameters | Description |
|
| Formatted outline of all top-level functions, classes, methods and data with line numbers, one |
|
| Syntactic search for all occurrences of an identifier (no scope awareness, so same-named unrelated identifiers show up too), matched per identifier rather than as a dotted path, returned as |
|
| Return source text of a single named symbol. |
|
| 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). In C, C++ and Java an overload takes its parameter types (A.m(int, String), A.m(), C f(void) being f()), a C++ definition outside its class its qualified name (A::f), and a name that a field shares with methods targets the field (the method is A.x()). A C/C++ tag or TS type alias or interface that shares its name with another symbol takes its keyword (struct stat, union u, class C, type User, interface Props), in front of a dotted path (type N.User). JS/TS exported declarations are targeted by their own names (export function add is add); an anonymous export default function/class is default (and its methods default.run), and const f = () => {...} is f. Decorated 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_symbolsNeed 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_importsWhere 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 |
|
New method in an existing class |
|
New field/attribute/member in a class |
|
New content before or after a top-level symbol |
|
New lines at the top of an existing function body |
|
New lines at the bottom of an existing function body |
|
New lines at a specific spot inside a function body (anchored to existing text) |
|
New parameter on an existing function |
|
New import or |
|
New name in an existing |
|
New comment above a symbol |
|
New Python docstring on a function/class |
|
New key in a dict/object/mapping/table (any lang) |
|
New item in a list/array/sequence (any lang) |
|
Modifying existing content
Intent | Tool |
Rewrite the full function (signature + body) |
|
Rewrite only the body, keep the signature |
|
Change one statement/block inside a large body |
|
Change only the signature, keep the body |
|
Change only the leading comment above a symbol |
|
Change only the Python docstring |
|
Change the value of an existing key (config, Python dict, JS/TS object) |
|
Removing content
Intent | Tool |
Remove a function, method, or class |
|
Remove one statement/line inside a function body (a route mount, a middleware call, a key of an inline object-literal argument) |
|
Remove a parameter from a function |
|
Remove an import or |
|
Remove one name from a multi-name named-import (Python or JS/TS) |
|
Remove a leading comment above a symbol |
|
Remove a key from a dict / config / JS-TS object literal |
|
Remove an item from a list/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, andadd_keyrefuses a key that already exists.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.
Escape hatches
Deleting several related symbols? Call
delete_symbolonce per target. There is nodelete_grouptool: the extra calls cost less than a grouped primitive would.Editing a sub-expression inside a method chain (dropping
.references(...)frominteger("x").notNull().references(...))? Usereplace_in_bodywith the literal snippet when it is unique; for arbitrary expression surgery use your normal edit tool, since ast-editor does no pattern matching on expression trees.Rewriting a whole file whose structure changes substantially? Use your normal write tool.
Valid code refused by the syntax check (very new language features, macro-heavy C/C++)? Make that edit with your normal edit tool.
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 | shWindows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Homebrew (macOS):
brew install uvpip (any platform):
pip install uvVerify the installation:
uv --versionFor more options (Docker, Cargo, WinGet, etc.), see the official uv installation docs.
uv 0.9.17 or newer is required. This server's pyproject.toml sets a 7-day dependency cooldown ([tool.uv] exclude-newer = "7 days"). Older uv versions can't parse that relative duration. They print a warning and ignore the setting. A plain uv sync or uv run, including the uv --directory … run launch commands below, then discards the committed uv.lock, re-resolves to the newest compatible versions (even ones published in the last 7 days), and rewrites the file. Upgrade with uv self update, brew upgrade uv, or pip install -U uv, depending on how you installed it. Upgrading doesn't undo an earlier rewrite: restore the original uv.lock with git checkout HEAD -- uv.lock in this folder, or copy it again from the archive you downloaded.
The server depends on mcp, tree-sitter, and a tree-sitter grammar package for each supported language. uv handles all of these automatically on first run.
Installation
Note: Replace
/absolute/path/to/ast-editorbelow with the absolute path to theast-editorfolder on your machine (the folder that contains itspyproject.toml).
Method 1: CLI Configuration (Claude Code, Codex, Gemini)
If your agent supports adding servers via CLI, run the following:
--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-mcpMethod 2: JSON Configuration
For tools that use a mcp_config.json or settings.json file, add the following block.
Important: Use the absolute path to
uvfor"command", not just"uv". GUI-based MCP clients (Claude Desktop, Cursor) 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 |
|
Cursor |
|
Windsurf | Agent Panel → "..." → MCP Servers → View raw config |
Antigravity |
|
Gemini CLI |
|
Antigravity IDE
The Antigravity IDE uses its own MCP config file and a uvx-based command — not the uv --directory … run form shown above. Add the server to ~/.gemini/config/mcp_config.json:
Important: Use the absolute path to
uvxfor"command", not a bare"uvx"— Antigravity doesn't inherit your shellPATH. Find it withwhich uvx(e.g./opt/homebrew/bin/uvx).
Note:
uvxignores both this server'suv.lockand the[tool.uv]settings in itspyproject.toml. It re-resolves dependencies on each launch instead of using the locked versions, and the 7-day cooldown is passed explicitly as--exclude-newer "7 days"inargs, so you get the newest compatible releases that are at least 7 days old. This requires uv 0.9.17 or newer: older versions reject the value (invalid value '7 days' for '--exclude-newer <EXCLUDE_NEWER>') and the server fails to start.
{
"mcpServers": {
"ast-editor": {
"command": "/absolute/path/to/uvx",
"args": [
"--exclude-newer",
"7 days",
"--from",
"/absolute/path/to/ast-editor",
"ast-editor-mcp"
]
}
}
}Using Standard Python (Fallback)
If you prefer not to use uv, run the following from the ast-editor folder (the one that contains its pyproject.toml), then point to the .venv executable it creates. python3 must be Python 3.10 or newer.
python3 -m venv .venv && source .venv/bin/activate && pip install .This path doesn't use uv, so the [tool.uv] 7-day dependency cooldown doesn't apply. With pip 26.1 or newer, add --uploaded-prior-to P7D to the pip install command to apply the same cooldown.
{
"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-editor when editing code and config files. The agent prompt lives in AST-EDITOR.md — a standalone file you wire into your agent's system instructions. It is kept short, since it is loaded in every session: it covers how to pick and call each tool, and this README is the full reference. The server also sends a short, condensed version of this guidance (start with list_symbols, pick the narrowest tool, what to do when an edit is refused for a syntax error) as MCP server instructions, which some clients, Claude Code among them, pass to the model automatically alongside any prompt file. Wiring in the full prompt file is still recommended. After updating the server, copy the new AST-EDITOR.md over the one you installed (for example ~/.claude/AST-EDITOR.md), since an installed copy does not update itself.
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.mdOr 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 |
|
Cursor |
|
GitHub Copilot |
|
Windsurf |
|
Antigravity |
|
Aider / Gemini CLI / generic | Rules file or system prompt |
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:
macOS:
~/Library/Logs/Claude/mcp*.logWindows:
%APPDATA%\Claude\logs\mcp*.log
For interactive testing, use the MCP Inspector, which provides a UI for exercising each tool with custom parameters and inspecting responses.
Available Tools
28 toolsadd_fieldA
Add a field or member at the top of a class body: before the first
member, after a Python class docstring or a Java enum's constants
(adding the ; they need), and below the access label a C++ class
opens with. It is indented like the class's members, with a blank line
after it when a method follows. A function target is refused.
Use this when: adding a class attribute, field or member variable. Don't use this when: adding a method (add_method).
Example: class_target="LRUCache" content='version = "1.0"'
Inputs: content (required), file_path (required), class_target (required). Returns: result
add_importA
Add an import line, skipping an exact duplicate. It goes into the group
of imports it belongs with (same package, standard library or not,
relative or not), at its sorted place when the group is sorted, else
after the group's last import. A comment heading the group stays first
(a next-line directive stays on its import), and so do Python from __future__ imports. With no fitting group it goes after the last
import, and a first import after the preamble.
Use this when: importing something the file does not import yet. Don't use this when: adding a name to an existing import (add_import_name).
Example: import_text="from typing import Optional"
Inputs: file_path (required), import_text (required). Returns: result
add_import_nameA
Add a name to an existing from <module> import ... (Python) or
import { ... } from "<module>" (JS/TS) statement; a name already
there is left as is. It goes at its sorted place when the names are
sorted (plainly or as isort and ruff sort them), else at the end, in the
list's layout: names one per line give it a line of its own, an inline
list stays inline. Refused when the file has no such import.
Use this when: the module is already imported with named imports. Don't use this when: there is no such import yet, or for a default or namespace import (add_import with the full line).
Example (from typing import Any, cast becomes Any, Dict, cast):
module="typing"
name="Dict"
Inputs: name (required), module (required), file_path (required). Returns: result
add_keyA
Add a key-value pair to a JSON object, YAML mapping, TOML table, Python dict literal or JS/TS object literal. The entry follows the container's layout: one key per line stays so, an inline object stays inline, and a trailing comma is kept. A YAML key with no value gets a nested mapping. A key the container already has is refused, naming the target to give replace_value.
Use this when: the key does not exist yet. Don't use this when: the key exists (replace_value), or you add a list item (append_to_array).
Example (TS, const CONFIG = { server: {...} }): parent_target="CONFIG.server" key="port" value="8080"
Inputs: key (required), value (required), file_path (required), parent_target (required). Returns: result
add_methodA
Add a method at the end of a class body, after its last member, spaced like the class's methods. In Go it goes after the type declaration. In a C++ class whose last method is followed by an access label, it goes right after that method, so it gets the same access. A one-line Python or Ruby class is refused, and so is a function target (use insert_in_body).
Use this when: adding a method to an existing class. Don't use this when: adding a field (add_field) or a top-level function (add_top_level).
Example: class_target="LRUCache" content='def clear(self):\n self.items.clear()'
Inputs: content (required), file_path (required), class_target (required). Returns: result
add_parameterA
Add one parameter to a function's signature, keeping the body. "end"
puts it after the last parameter but before those that must come after
it (Python **kwargs, a JS/TS rest parameter, Go and Java variadics,
C/C++ ..., Ruby **opts and &blk); "start" before the first, but
after a TS this or Java receiver parameter. A list written one
parameter per line gets it on a line of its own, a C (void) takes it
in place of void, and a bare JS/TS arrow parameter gets parentheses.
A name the function already has is refused.
Use this when: adding a parameter without retyping the signature. Don't use this when: rewriting the signature (replace_signature), or changing the body too (replace_function).
Example (def f(a, **kw) becomes def f(a, b, **kw)):
target="f"
parameter="b"
Inputs: target (required), file_path (required), parameter (required), position. Returns: result
add_top_levelA
Add top-level code (a function, class, constant or other statement) to a source file. "bottom" appends it at the end, inside a header's include guard; "top" puts it after the preamble, before the first declaration. It is set apart by a blank line, two around a Python def or class, but one-line code added at the bottom under stacked one-line code is stacked too. In Go, or a file indented with tabs, its indentation becomes tabs.
Use this when: adding a top-level symbol or statement. Don't use this when: placing it next to a symbol (insert_sibling), in a class (add_method, add_field), in a body (insert_in_body) or in a config file (add_key).
Example: content="MAX_CONNECTIONS = 10" position="top"
Inputs: content (required), file_path (required), position. Returns: result
append_to_arrayA
Append a value to a JSON, YAML or TOML array or a Python module-level
list. The value follows the array's layout: an inline array stays
inline ([1, 2] becomes [1, 2, 3]), one element per line gets a new
line, and a trailing comma is kept. Comments are not elements: the
comma goes before them.
Use this when: adding a list item. Don't use this when: adding a key-value pair (add_key).
Example: target="project.dependencies" value='"new-package"'
Inputs: value (required), target (required), file_path (required). Returns: result
delete_in_bodyADestructive
Delete a snippet inside a function's body, matched as replace_in_body
matches one. A deletion that empties its lines removes them whole; a
comment after the snippet on its last line stays. One that would leave
a Python body without a statement is refused (replace the snippet with
pass).
Use this when: removing a statement, block or line in a body, or an entry of an object literal passed as an argument. Don't use this when: deleting a whole function or class (delete_symbol).
Example: target="RegisterRoutes" snippet='r.Mount("/kb", kbHandler)'
Inputs: target (required), snippet (required), file_path (required). Returns: result
delete_keyADestructive
Delete a key-value pair from a config file, or from a Python or JS/TS
module-level dict or object literal. The adjacent comma goes too where
the syntax has one, and deleting the only entry leaves {}; YAML and
TOML keys otherwise go with their lines.
Use this when: removing a whole entry. Don't use this when: removing a list item (remove_from_array), or an entry of an object literal passed as an argument (delete_in_body).
Example: target='CONFIG."timeout"'
Inputs: target (required), file_path (required). Returns: result
delete_symbolADestructive
Delete a whole function, method, class or other symbol, with its
decorators, a JS/TS export and a C++ template <...> header. The
blank lines that set it apart go too, so the file keeps its spacing. A
symbol that shares a line with other code is cut out of the line. One
name of a declaration of several (int a, b;, struct S {...} s;) is
refused: give replace_function the declaration without it. Refused in
JSON, YAML and TOML files (use delete_key).
Use this when: removing a symbol along with its doc comment. Don't use this when: removing a config key (delete_key), an import (remove_import) or lines inside a body (delete_in_body).
Example: target="LRUCache.old_method"
Inputs: target (required), file_path (required), include_leading_comments. Returns: result
edit_leading_commentADestructive
Add, replace or remove the comment block directly above a symbol, its
doc comment. The block sits above the symbol's decorators, annotations,
export and C++ template <...> header, and a new comment goes there,
re-indented to the symbol. A comment indented deeper than the symbol
ends the code above and is not part of the block. A symbol that other
code comes before on its line is refused. In YAML and TOML the target
is a key path or a TOML [table]; JSON has no comments and is refused.
Use this when: adding, rewriting or deleting a symbol's doc comment. Don't use this when: writing a Python docstring (replace_docstring) or a comment inside a body (insert_in_body, replace_in_body).
Example: target="LRUCache.get", op="add", comment="# Return the cached value."
Inputs: op (required), target (required), file_path (required), comment. Returns: result
find_referencesARead-only
List each occurrence of an identifier in a source file as line N: <source line>, in file order. Read-only and syntactic, with no scope
analysis, so unrelated identifiers of the same name show up too. C++
destructors and operators go by their list_symbols names (~A,
operator==).
Use this when: surveying a name's uses in a file before a rename. Don't use this when: you need references across files or by scope (use a language server).
Example: target="LRUCache"
Inputs: target (required), file_path (required). Returns: result
insert_in_bodyA
Insert new_snippet inside a function's body. Set exactly one of at,
after or before. at="top" goes after a Python docstring,
at="bottom" at body level after the last statement, and in an empty
body one level deeper than the function; at is refused for a body on
its signature's line (use replace_function_body).
An anchor is matched as replace_in_body matches a snippet and must be
unique. One that ends its line (after) or starts it (before) gets
new_snippet on lines of its own; after a line that opens a block (if x:, if (x) {), new lines go inside the block. A mid-line anchor gets
new_snippet byte for byte.
Use this when: adding lines to a body. Don't use this when: replacing the whole body (replace_function_body), changing existing lines (replace_in_body), or adding a top-level symbol (add_top_level).
Example (both written without indentation): target="handle" new_snippet='auth_check(request)' before='validate(request)'
Inputs: target (required), file_path (required), new_snippet (required), at, +2 more. Returns: result
insert_siblingA
Insert code next to a named symbol, at its level. "before" goes above
the symbol's leading comment, decorators and export; "after" below
the symbol (after the ; that ends a C/C++ class). The code is spaced
like the gap it goes into: one-line code stacked without blank lines
stays stacked, and next to a function or class it gets at least one
blank line, two around a top-level Python def or class. A comment
indented deeper than the target ends the code above, so "before" goes
below it. "before" is refused when other code comes before the target
on its line, "after" when code follows it. Refused in JSON, YAML and
TOML files (use add_key).
Use this when: placing code relative to a symbol, such as a helper above its caller. Don't use this when: appending at the end of the file (add_top_level), or inserting into a body (insert_in_body) or a class (add_method, add_field).
Example: target="LRUCache" content="CACHE_SIZE = 100" position="before"
Inputs: target (required), content (required), position (required), file_path (required). Returns: result
list_symbolsARead-only
List the symbols of a source file, one per line as <kind> <name> (line N), class members indented under their class (method A.B.m). Each
name is the target the other tools take: overloads with their
parameter types (A.m(int)), a C++ definition outside its class by
its qualified name (A::f), and a struct or TS type that shares its
name with another symbol after its keyword (struct struct stat
targets struct stat). Kinds include function, method, class, struct,
enum, interface, namespace, type, macro, field, variable and constant;
a Go var x = func(...) {...}, listed as a variable, also takes the
body, parameter and signature tools. A name with a prototype or
forward declaration is listed once, at its definition. Read-only.
Use this when: before editing a file, to get exact target names. Don't use this when: you already know the target (read_symbol reads one symbol).
Example: file_path="/abs/path/to/module.py"
Inputs: file_path (required). Returns: result
read_importsARead-only
Return the import statements of a source file (imports, includes, Ruby requires), without reading the rest. Read-only.
Use this when: checking a file's dependencies, as before add_import. Don't use this when: changing imports (add_import, remove_import).
Example: file_path="/abs/path/to/module.py"
Inputs: file_path (required). Returns: result
read_symbolARead-only
Return the source of one symbol (function, class, method, variable) or
the value of a config key, without reading the whole file. Read-only.
"full" is the text replace_function and delete_symbol take: decorators,
a JS/TS export, a C++ template <...> header and code that only
wraps the symbol (a Go type, a closure's key, a C typedef)
included, a C/C++ class ending with its ;. It starts at the symbol's
first byte, the later lines at their file indentation. "interface"
gives a class's header, fields and method signatures (bodies as
...), indented as in the class, or a function's signature;
"signature" the signature or class header alone, after decorators.
Both refuse a variable, constant, field, type alias or macro, saying
what it is. A name with a prototype or forward declaration reads as
its definition.
Use this when: reading one symbol; pick the narrowest depth. Don't use this when: you need the file's outline (list_symbols) or its imports (read_imports).
Example: target="LRUCache", depth="interface"
Inputs: target (required), file_path (required), depth. Returns: result
remove_from_arrayADestructive
Remove the first element matching value_match from a JSON, YAML or
TOML array or a Python module-level list. An element on a line of its
own goes with that line and its comment; other comments stay. The
trailing-comma style is kept, and removing the only element leaves
[].
Use this when: removing one list item. Don't use this when: removing a whole key (delete_key).
Example: target="project.dependencies" value_match='"old-package"'
Inputs: target (required), file_path (required), value_match (required). Returns: result
remove_importADestructive
Remove an import line, matched by its text with surrounding whitespace stripped.
Use this when: removing an unused import. Don't use this when: removing one name from a multi-name import (remove_import_name).
Example: import_text="import os"
Inputs: file_path (required), import_text (required). Returns: result
remove_import_nameADestructive
Remove one name from a from <module> import ... (Python) or import { ... } from "<module>" (JS/TS) statement. A name on a line of its own
goes with that line and its comment. Removing the only name removes the
statement, but is refused when a default import would be left with
empty braces (import D, { a } from "m"): use remove_import, then
add_import.
Use this when: dropping one name from a multi-name import. Don't use this when: removing the whole import line (remove_import).
Example: module="typing" name="List"
Inputs: name (required), module (required), file_path (required). Returns: result
remove_parameterADestructive
Remove a parameter by name from a function's signature, keeping the
body. A parameter on a line of its own goes with that line and its
comment. Removing the only one leaves (), or (void) in C. In Python
a * or / left invalid goes too, and a *args with keyword-only
parameters after it leaves a bare * (def f(a, *args, b) minus args
is def f(a, *, b)). One Go name of a, b int, or one name that a
destructuring parameter binds, goes alone. Refused: a name the function
lacks, and in C the last named parameter before ....
Use this when: dropping one parameter without retyping the signature. Don't use this when: rewriting the signature (replace_signature).
Example: target="LRUCache.get" parameter_name="default"
Inputs: target (required), file_path (required), parameter_name (required). Returns: result
replace_docstringADestructive
Replace or add the docstring of a Python function, method or class, or
of the module with target "". new_docstring must be one string literal
with its quotes; anything else (bare text, an f-string, bytes, an empty
value) is refused. A new docstring goes first in the body; the module's
goes below a #! line and encoding or header comments. A new docstring
for a body on its signature's line is refused (use
replace_function_body); so is any non-Python file.
Use this when: adding or updating a Python docstring.
Don't use this when: editing a # comment above the symbol
(edit_leading_comment).
Example: target="LRUCache.get" new_docstring='"""Return the cached value, or None."""'
Inputs: target (required), file_path (required), new_docstring (required). Returns: result
replace_functionADestructive
Replace a whole symbol (decorators, signature and body) with new content.
The replaced span is what read_symbol returns: decorators, a JS/TS
export, a C++ template <...> header and code that only wraps the
symbol are part of it, so keep them in content. read_symbol's text,
edited or not, goes in as it is, and may become several members;
content written from column 0 is re-indented to the symbol's level. A
symbol that shares a line with other code has only its own text
replaced. For one name of int a, b; the content must keep the others.
Refused in JSON, YAML and TOML files (use replace_value).
Use this when: rewriting a symbol top to bottom (name, parameters and body). Don't use this when: only the body changes (replace_function_body), only the signature (replace_signature), or a few lines (replace_in_body).
Example: target="LRUCache.get" content='def get(self, key, default=None):\n return self.items.get(key, default)'
Inputs: target (required), content (required), file_path (required). Returns: result
replace_function_bodyADestructive
Replace only the body of a function, keeping its signature and decorators. A body on its signature's line is rebuilt over lines of its own, and an empty body gets content one level deeper than the function. A Ruby method's rescue, else and ensure clauses stay and only the code before them is replaced, unless content has clauses of its own. Refused for a function without a block body: a Ruby endless method, or an arrow function whose body is an expression (use replace_function).
Use this when: changing the implementation but not the interface. Don't use this when: the parameters or return type change too (replace_function), or only a few lines change (replace_in_body).
Example: target="LRUCache.get" content='if key in self.items:\n return self.items[key]\nreturn None'
Inputs: target (required), content (required), file_path (required). Returns: result
replace_in_bodyADestructive
Replace a snippet inside a function's body; the match is scoped to that body, so text elsewhere in the file never matches. A snippet found nowhere is refused; one found in several places is refused with their line numbers: add context.
Use this when: changing one statement or block in a long body. Don't use this when: replacing the whole body (replace_function_body), or editing a sub-expression no unique snippet can locate (your normal edit tool).
Example (a block written without its indentation): target="handle" old_snippet="if retry:\n wait()" new_snippet="if retry:\n backoff()\n wait()"
Inputs: target (required), file_path (required), new_snippet (required), old_snippet (required). Returns: result
replace_signatureADestructive
Replace the signature of a function, or the header of a class, keeping
its body. Existing decorators, Java annotations and a C++ template <...> header stay unless new_signature has its own. Code that
read_symbol's signature starts with (a JS/TS const f =, a closure's
key, a TS declare, a Ruby private) is replaced when new_signature
starts with it, else kept. A method without a body keeps its ;.
Refused for a symbol with no signature, such as a variable.
Use this when: changing a function's parameters, return type or name. Don't use this when: the body changes too (replace_function), or one parameter is added or removed (add_parameter, remove_parameter).
Example: target="LRUCache.get" new_signature="def get(self, key, default=None):"
Inputs: target (required), file_path (required), new_signature (required). Returns: result
replace_valueADestructive
Replace the value of an existing config key, dict or object entry, or
of a variable, constant, field or macro in a source file (X = 1,
const X = 1;, int x = 1;, #define N 3). Only the value changes; a
declaration without one gets one (let x; becomes let x = 2;, YAML
a: becomes a: 2). Refused: a function, class or type (use
replace_function), a name sharing one value with others (a = b = 1)
and a declaration that cannot have a value (extern, TS declare).
Array elements are not addressable.
Use this when: updating a value that exists. Don't use this when: the key is new (add_key), or you edit an array (append_to_array, remove_from_array).
Example: target="project.version" content='"2.0.0"'
Inputs: target (required), content (required), file_path (required). Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 28 tools
Despite the large surface, each tool has a clearly bounded scope: replace_function versus replace_function_body versus replace_in_body are explicitly separated by edit granularity, and add_top_level versus insert_sibling versus add_method are separated by placement. The use/don't-use guidance and cross-references make misselection unlikely.
All names are snake_case and verb-first, with predictable object or context suffixes (add_import_name, remove_import_name, replace_function_body, delete_in_body). The verb variation is semantically meaningful and consistent with the operation being performed.
At 28 tools, the surface is well beyond the typically manageable 3-15 range and feels heavy for a single editing server. The granularity is intentional, but several operations could be consolidated or made more discoverable.
The set covers a broad editing lifecycle: read/list symbols and imports, edit functions, signatures, parameters, bodies, comments, imports, config keys, and arrays. Minor gaps remain, such as no direct array-element replacement and no explicit rename/copy/move symbol operation, but most work is still achievable through existing tools.
Maintenance
Related MCP Connectors
Shared memory MCP server for Claude Code, Codex, Gemini CLI and Cursor. Cited, editable.
Deterministic memory & code intelligence MCP for 15 IDEs (100% LongMemEval in 12.9ms, 0 LLM tokens).
141MCP server for progressive tool usage at any scale (see https://klavis.ai)
A MCP server built for developers enabling Git based project management with project and personal…
Related MCP Servers
- AlicenseBqualityDmaintenanceAdvanced code search and transformation MCP server for AI assistants. Combines ugrep's speed with intelligent replace capabilities, dry-run previews, and language-aware refactoring across 11 tools.110MIT
- AlicenseAqualityBmaintenanceEnables AI assistants to search and analyze codebases using Abstract Syntax Tree (AST) pattern matching with ast-grep. Supports structural code search, pattern testing, and AST visualization across multiple programming languages.4495MIT
- AlicenseNot gradedqualityCmaintenanceProvides advanced code structure and semantic analysis through Abstract Syntax Trees (AST) and Abstract Semantic Graphs (ASG) across multiple programming languages. It enables tasks like incremental parsing, complexity analysis, and AST diffing to help models understand and navigate codebases.37MIT
- AlicenseAqualityBmaintenanceA 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.2810MIT