Skip to main content
Glama

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:

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 insert_in_body(at="top") / insert_in_body(at="bottom"))

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_symbol(depth="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

read_symbol(depth="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 insert_in_body (at="top" / at="bottom", or anchored with after= / before=) for small additions instead of rewriting whole function bodies

  • Input tokens: Using read_symbol (depth="full" / "interface") / read_imports to 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 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. Nested classes (decorated ones too) are listed with their members (class A.B, method A.B.m). Module-level dict/list literals editable via add_key / replace_value / delete_key / append_to_array (nested dicts addressed by key path, e.g. CONFIG."db"); comments in a list are not elements, so append_to_array puts the comma before them. Module-level variables (X = 1, Y: int = 2) are listed as variable and class attributes as field (A.x), at their first assignment; replace_value replaces their value. @overload stubs directly before their function are one symbol with it, listed once at the first stub. A name defined again (a redefinition, a @property setter) is listed again, and the name targets the first.

JavaScript

.js, .jsx, .mjs, .cjs

✅

✅ // + /* ... */

—

Exported declarations (export function, export class, export default function c) are listed and targeted by their own names, an anonymous export default function () {} / export default class {} as default; the export is part of the symbol, so read_symbol shows it and replace_function content should keep it. Generator functions (function* g()) and each name of a const/let/var bound to an arrow function or function expression (const f = (x) => {...}, exported or not) are listed and targeted as functions: read_symbol, replace_function, delete_symbol, the comment tools, insert_sibling and the parameter tools work on them, and the body tools on those with a { } body (one whose body is an expression, (x) => x * 2, is refused: rewrite it with replace_function). Their signature is const f = (x) =>, and replace_signature replaces from the const when the new signature starts with it, else only (x) =>; a lone unparenthesized parameter gets parentheses from the parameter tools (x => ... becomes (x, y) => ..., or () => ... when it is removed). Module-level const/let/var object literals (exported or not) editable via add_key / replace_value / delete_key; nested objects addressed by dotted path (CONFIG.server). Other data is listed too: each name of a const as constant and of a let/var as variable, and class fields as field (A.x, A.#y, static, decorated and declare ones), which the whole-symbol tools and replace_value (its value alone) take; a method or function wins over a class field or var of its name (static run = false; beside run() {}), and a setter beside its getter is not listed (the name targets the getter). Computed and quoted names are targets as listed (E.[Symbol.iterator], E.'a.b'). A comment after a symbol on its last line (function f() {} // f) is no part of it, as in the other languages. CommonJS require statements count as imports for add_import.

TypeScript

.ts, .tsx

✅

✅ // + /* ... */

—

Exports, generators and functions bound to names as for JavaScript. Abstract classes (abstract class A, exported or not) are classes for every tool, and their abstract methods are listed (an overloaded one once) and targeted (A.m) like the others, replace_signature included (the final ; stays); enums, type aliases and interfaces are listed as enum, type and interface and can be read, replaced, deleted, commented and used as insert_sibling targets. Interfaces are treated as classes for add_method / add_field, which refuse an enum or type alias (rewrite those with replace_function); a type alias has no depth="signature". A type alias or interface that shares its name with a value in one scope, as in zod's export const User = z.object(...) with export type User = z.infer<typeof User> (or an interface and a function), is targeted with its keyword, type User or interface Props (listed as type type User; with a dotted path the keyword goes first, type N.User), and the plain name targets the value. Overload signatures directly before their implementation (through export and declare, comments and the implementation's decorators between them; a run of signatures alone too) are one symbol with it, listed once at the first signature: read_symbol, replace_function and delete_symbol take them all, insert_sibling(position="before") and edit_leading_comment go above the first, and the signature depths, replace_signature, the parameter tools and the body tools work on the implementation. Namespaces (namespace N {}, module N {}) are listed as namespace with their classes and functions (N.C, N.f); one opened again, or merged with the class, function or enum of its name, is one container: the name targets the class, function or enum, its members and the namespace's are found and listed under it, and none of this is ambiguous. declare declarations are listed like plain ones (read_symbol shows the declare), and an abstract method reads with its ;. A namespace's interface ends with its }, without a comment after it. .tsx files are parsed with the TSX grammar, so JSX is valid there. Object literals editable as for JavaScript, including { ... } as const and satisfies T.

C

.c, .h

✅

✅ // + /* ... */ (single + multi-line)

—

.h defaults to C — use .hpp/.hxx/.hh for C++ headers (adding C++-only syntax to a .h file that parses as C is refused by the save-time syntax check). A struct, union or enum ends with the ; after its }: read_symbol shows it, replace_function content should end with };, and delete_symbol and insert_sibling(position="after") take it, also when it is on a line of its own. Listed as struct, union, enum, function, macro (#define, function-like ones over several lines too), type (typedefs; an anonymous typedef struct {...} P; lists its fields as P.x and takes add_field), field, variable and constant (const but not a pointer), including what #if/#ifdef blocks (include guards too) and extern "C" hold; a struct defined in a declaration or typedef is shown whole by read_symbol (struct S {...} s;, typedef struct N {...} N;). A definition wins over its forward declaration (struct S;), prototype or extern declaration in either order, and is what list and target; a prototype alone is a bodiless function (replace_signature and the parameter tools work on it). A definition outside #if/#ifdef blocks wins over one inside (#if 0), and of alternative definitions in #ifdef/#else branches the first is listed and targeted. A struct, union or enum that shares its name with a function, variable or typedef is targeted with its keyword (struct stat, listed as struct struct stat), and the plain name targets the other symbol (typedef struct N {...} N; stays one symbol, and a typedef of a struct declared apart, typedef struct Node Node;, is Node); add_method and add_field look only at containers, so stat is the struct there. Without overloading, f(int) only checks the parameter types. replace_value replaces the value of a global (int g = 1;, int a[] = {1}) or a macro's text (#define N 3), and gives one to a declaration without one (a C struct's field cannot have one and is refused); for a name that a macro shares with a function, the name is the macro and the function is name(types). A type that is only used (struct S *p;) and variables inside function bodies are not targets.

C++

.cpp, .cc, .cxx, .hpp, .hxx, .hh

✅

✅ // + /* ... */ (single + multi-line)

—

Supports class, struct, union, enum, namespace; Class::method qualified names resolve correctly. Classes end with their ; as in C. A template's template <...> header is part of its symbol, as decorators are: read_symbol shows it, replace_function content should include it, delete_symbol removes it, a leading comment and insert_sibling(position="before") go above it, and replace_signature keeps it unless the new signature starts with template. Template functions, classes and member templates are listed at the line of their header, an explicit specialization (template <> int id<int>(int)) under the name it specializes, and a definition outside its class (int A::f(), template <typename T> T Box<T>::get()) as a function under its qualified name (A::f, Box::get, A::A; the last name alone, or a shorter tail, still finds it when nothing else in the scope has that name). Overloads take their parameter types, written as declared without names, defaults or attributes, spacing free (A.m(int), A.m(const std::string &, int), A::A(int), A.operator()(int)), and method qualifiers where only those differ (A.get(int) const); spellings of one type match (unsigned and unsigned int, char const * and const char *, int[] and int *, a const on the parameter itself, struct S * and S *), so a prototype and a definition that spell it differently are one function. The bare name of an overloaded function, and a name several qualified names end with, are refused with the targets to use. A class or namespace declared with its scope (struct A::B {...}, namespace a::b {}) is A::B, and class X targets a class that shares its name with another symbol. Everything listed for C (constexpr variables as constant too), plus class, namespace (members as ns.f; a namespace opened again in its scope is listed once with the members of all its blocks, and a target finds a definition in any of them), nested classes (class A.B), using aliases as type, static members, methods declared in their class, and structured bindings (auto [x, y] = p;). Destructors and operators are named as written without spaces, ~A, operator==, operator(), operator bool, and targeted as A.~A or A.operator== (any spacing matches); find_references finds them.

Ruby

.rb

✅

✅ #

—

Classes, modules, instance methods, singleton_method (class methods via def self.foo). require/require_relative/load/autoload recognized as imports. A statement that opens a heredoc (X = <<~EOS) ends with the heredoc's body, for read_symbol and the whole-symbol edits. An endless method (def f = x) has no body for the body tools. Classes and modules nested in modules are listed with their members, private def m and class << self methods too (read_symbol shows the private). Embedded documents (=begin ... =end at column 0) stay at column 0 when content is re-indented, and an edit that would leave a =begin indented is refused; one directly above a method is its leading comment. A Sorbet signature directly above a method (sig { ... }, sig do ... end) is part of it, as a decorator is, and the signature depths start at its def. Constants are listed as constant and locals as variable. A class reopened in one scope is listed for each block, and the name targets the first; a dotted target finds a member in any of them. Interfaces show every method as its signature plus ..., empty and endless ones too.

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. Type aliases (type T = int) are listed as type, and every name of a var/const as variable/constant. read_symbol shows the type, var or const of a declaration of one spec (a spec in a type (...) group alone) and a closure's key (RunE: func() error {...}). remove_parameter removes one name of a, b int, keeping the others' comments. A var x = func(...) {...} is a function for the parameter, body and signature tools (listed as variable; one of several values, var p, q = func() {}, func() {}, has its own signature). replace_value on a constant without a value that the constants below it repeat is refused, as a value would change theirs.

Java

.java

✅

✅ // + /* ... */ + Javadoc /** ... */

—

class, interface, enum, record; methods, constructors, fields. Annotations (@Override, @Deprecated) travel with their method on edits (wrapped in the modifiers node); replace_signature keeps them unless the new signature includes its own, and also works on interface and abstract methods, which have no body. Enum methods (nested in enum_body_declarations) discovered via BFS in list_symbols. Fields and interface constants are listed as field A.x and targetable; a field that shares its name with a method is the name's target, and the method is A.x(). Overloaded methods and constructors take their parameter types (A.m(int), A.m(String...), A.join(List<String>, String), A.A()), without modifiers and annotations; the bare name is refused with the targets to use. Nested classes, enums, interfaces and records are listed with their members, and a nested class whose name a field takes is left out, its members still listed (A.B.m).

JSON

.json

✅ (keys, values, arrays)

— (no comment syntax)

—

Numbers with a + in the exponent (6.02E+23), which the grammar cannot parse on its own, are read as whole values, so replace_value, delete_key and add_key beside them work as for any other number.

YAML

.yml, .yaml

✅ (keys, values, sequences)

✅ #

—

Block and flow sequences supported. Keys inside flow mappings are addressable by path (a: {x: 1} is a.x, nested ones too), so read_symbol, replace_value and delete_key work on them and they stay inline. add_key also adds to flow mappings ({a: 1}, {}) and to a key with no value (parent:). A key with no value can be read (as an empty string), commented, and given a value with replace_value (a: 3; content of several lines, or a block sequence or mapping, goes below the key one level deeper); an explicit key with no value (? a) gets its value on a : 3 line below it, in the column of the ?. In a multi-document file a path is looked up in each document in turn (add_key with parent_target="" adds to the first document with content). Quoted keys are matched by the name they spell, so 'it''s' is target it's and "caf\u00e9" is café. Removing the only item of a block sequence leaves key: [].

TOML

.toml

✅ (keys, values, arrays, tables)

✅ #

—

[table] headers (dotted and quoted ones too, [tool.uv], [a."b.c"]) addressable by their names for comment tools and add_key. Keys inside inline tables (t = { a = 1 } is t.a, nested ones too) and dotted keys (x.y = 1 is x.y) are addressable by path. add_key also adds to inline tables (t = { a = 1 }, t = {}).

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_function and delete_symbol include them; a leading comment belongs above the first of them (edit_leading_comment puts it there and finds an existing one there), and insert_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's template <...> header and for a Ruby Sorbet sig. replace_signature keeps 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/#ifdef block (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 in read_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() {} }, Go struct{ 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 stat and int stat(...)) is the struct, a typedef of a struct declared apart (typedef struct Node Node;) is that struct, and a function target is refused (use insert_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 inline parent: { key: value }). YAML and TOML keys are quoted only when they need it: my key and a.b stay plain in YAML, while a:b, yes, 123 or 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, so b.c adds c under b and 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 to replace_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 - value item.

  • Config paths from the top level: a dotted config target is followed from the top of the file, one key per segment: a.b is key b under the top-level key a, never an a.b nested deeper (such as x.a.b), and in TOML a plain version is a top-level key, not one inside a [table] (use project.version). One path syntax covers JSON, YAML and TOML and every config tool (read_symbol, replace_value, add_key's parent_target, delete_key, append_to_array, remove_from_array and edit_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" finds a.b: 1, 'a.b': 1 and "a.b": 1 in YAML, and [x.'y.z'] and [ x . "y.z" ] are both TOML table x."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.0 is a key named 0).

  • Byte-correct slicing: multi-byte characters (emoji, ═, →) handled safely in source text.

  • Import placement: add_import skips 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 CommonJS require as 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" in foo.cpp). Go: standard-library paths (no dot in the first path element) with standard-library paths, others with others, never with cgo's import "C". JS/TS: relative paths (./, ../) with relative paths, packages with packages, a require with requires. Java: static with static. Ruby: require_relative with require_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 z goes with from 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 x lines before from x import lines 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 tslint disable-next-line, pylint disable-next, biome-ignore, deno-lint-ignore, prettier-ignore, NOLINTNEXTLINE, Flow suppressions, istanbul, c8 or v8 ignore next, Go //nolint and //lint:ignore, noinspection). The blank lines between groups stay as they are. Python from __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; a from __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 an import ( ... ) 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 as import "fmt"; and nothing goes between import "C" and its cgo preamble comment (the new import goes after import "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_symbol by default removes the contiguous leading comment block above the symbol (Godoc, Javadoc, #/// comment runs) so docs don't become orphaned. Opt out with include_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 f under 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, and edit_leading_comment neither removes nor replaces it. Such comment lines right below a symbol's last line belong to it: delete_symbol removes them with it and insert_sibling(position="after") goes below them (read_symbol and replace_function leave them out). A Ruby =begin ... =end document 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 } minus b is { 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_function likewise 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 .py files 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 .json files the json module 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 \n line breaks: inserted text is converted to the file's style, and a \n in 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 own def is 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 (\n matching CRLF) where it neither starts nor ends inside a token of code, an identifier, number, keyword or operator: x = 1 is not found in max = 10, nor = 1 in a == 1, el in $el or foo in Ruby foo?; 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 with pass). 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_body puts 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 in read_symbol's shape goes in as written), and inside the block that an after anchor'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 =begin documents, 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 in attr_accessor — you provide the exact text you want (whether that's attr_accessor, attr_reader, @instance_var = nil in initialize, or CLASS_CONST = 42).

  • Go: add_field("Cache", "\tversion int") inserts the literal string inside the struct { ... } body. The tool does not infer types from bare names — you provide the full Go field declaration.

  • Rationale: consistent with how add_field works 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 :foo from the name foo) — 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 the type 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_function

file_path, target, content

Replace a full function definition (signature + body + decorators, and a JS/TS export or C++ template <...> header): exactly what read_symbol returns, so its text can be edited and given back as is, and given back unchanged leaves the file byte for byte as it was; that shape may hold several members (a method replaced by two, a field by two fields, a method and a decorated one), each starting at the symbol's indentation once the one before has ended. Content written relative (first line at column 0, the rest indented from it) or at its file indentation is re-indented as before. When no line sits at the symbol's own indentation to tell these shapes apart, content whose later lines are all deeper than the symbol's indentation is taken as read_symbol's text and keeps them where they are (a 4-space method body for a 2-space class stays 4 spaces in); a decorator or annotation at the symbol's indentation counts only before the first line of code; at column 0 the shapes are one and content goes in as written. A comment before the symbol on its first line (/** @type {X} */ let x;) is no part of it and stays, and the content's later lines are indented as that line, or as the line where a block comment ending there ( */ void f() {) starts. A comment after the symbol on its last line stays unless the content ends with one, and so does a ; or , after it unless the content ends with it (a JS/TS class field's goes when the content is a method). A name declared together with others (int a = 1, b = 2;, struct S {...} s;) has that declaration replaced, and content that would drop the others is refused. A symbol that shares a line with other code has only its own text replaced (see Layout-aware deletion above). Refused in JSON, YAML and TOML files (use replace_value).

replace_function_body

file_path, target, content

Replace only the body of a function, preserving signature and decorators. A body written on one line with its signature or braces (function f() { return 1; }, def f(): return 1) is rebuilt over lines of its own, one level deeper than the function, as is content for an empty body ({ and } on lines of their own, and an empty Ruby method, def f over end or def h; end, or one holding only comments, which the content replaces); a comment that ends a one-line Python body stays at the end of the def's line (def b(): # old). A Ruby endless method (def f = x) is refused. In a Ruby method with rescue, else or ensure clauses only the code before the first clause is replaced and the clauses stay: def f\nrescue => e\n log(e)\nend with content 1 gives def f\n 1\nrescue => e\n log(e)\nend. With no code before the clause the content goes one level deeper than the def, as in an empty method; a clause on the same line as code moves to a line of its own; content with clauses of its own replaces the whole body, clauses included. When a one-line Ruby body is rebuilt, clauses that share a line with their code or the end are rebuilt too (def f; x; rescue; y; end becomes def f\n 1\nrescue\n y\nend); clauses already on lines of their own keep their text.

replace_signature

file_path, target, new_signature

Replace only the signature, preserving the body. new_signature is everything from the start of the definition up to its body (def get(self, key):, JS get(key, d) without the {). Existing decorators (Python, JS/TS), Java annotations and a C++ template <...> header are kept unless new_signature includes its own, which replace them. Indentation on the signature's first line is ignored, so "def get(self):" and " def get(self):" both work for a method; the lines after it may be indented relative to it ("@staticmethod\ndef get(key):") or written as read_symbol(depth="signature") returns them. For a JS/TS const f = (a) => {...}, "const f = (a, b) =>" replaces from the const on and "(a, b) =>" only the function's own part; likewise a closure's key (handler: (a) =>), a TS declare, a C/C++ typedef or extern "C", a Go type or var, a Ruby private and a Sorbet sig are replaced by a new signature that starts with its own, and kept otherwise (an export stays; a new closure key renames it). A TS overload group and a Python @overload group have the implementation's signature replaced, and a Ruby endless method keeps its = expression. A method declared without a body (a TS abstract method, a Java interface or abstract method, a C++ A() = default;, a Go function without one, a C/C++ prototype or a method declared in its class, int f(int);) is all signature but its final ;, which stays whether or not new_signature ends with one. A Go struct or interface type and an empty Ruby class, module or method take the header read_symbol(depth="signature") gives them (type A struct, class A < B); a variable or constant has no signature and is refused.

replace_in_body

file_path, target, old_snippet, new_snippet

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). new_snippet is indented as the snippet matched; '' deletes.

delete_in_body

file_path, target, snippet

Delete a snippet inside a function body, matched as replace_in_body matches one. Lines left without code go whole, with their indentation and line break; a comment after the snippet stays; a Python body keeps a statement.

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). at inserts at the body's own indentation level even when the body ends in a nested block; a Python docstring stays first; a body written on one line with its signature or braces is refused (rewrite it with replace_function_body); an empty body (also an empty Ruby method) takes the content one level deeper than the function. An anchor is matched as a replace_in_body snippet; one that ends (after) or starts (before) its line gets the new text on lines of its own, indented like it and inside the block its line opens (see Body snippets and anchors above), else byte for byte, as does an anchor in a body on its signature's line. A line break at the start of new_snippet after an anchor that has none, and spaces after its last line break, are taken for the anchor line's own, and a final line break is added. In an empty body there is no code to anchor to (an anchor on its brace inserts byte for byte); use at="top" or at="bottom" there. after an anchor that ends with the body's closing brace is refused.

add_top_level

file_path, content, position="bottom"

Insert top-level content. position="bottom" appends at end of file (default), in a header inside its include guard, before the #endif; position="top" inserts after the preamble (as for a first import: package, #! and magic comments, directives, imports and includes, a header comment, the Python module docstring, a header's include guard) and before the first real declaration, without splitting a comment from the code it documents. The content is separated from its neighbours by one blank line, two in a Python module when either is a def or class (PEP 8); at the top, more blank lines the file had there stay below it; at the bottom, one-line content after one-line code that is stacked with the code above it is stacked too, but not after imports, includes or a package clause (unless it is an import itself). read_symbol's text of a nested symbol (recognised by its closing line or a decorator) is moved to column 0 as a whole.

add_method

file_path, class_target, 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_method / add_field above).

add_field

file_path, class_target, content

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_sibling

file_path, target, content, position

Insert content as a sibling of a named symbol. position="before" or "after"; "before" goes above the symbol's leading comment block, decorators, a JS/TS export and a C++ template <...> header. The new code goes into the gap between the target and the symbol on that side (with that symbol's comment, decorators and export) and is spaced like it: next to a function, class or other code over several lines, by that gap or the usual one when wider (one blank line, two at the top level of a Python module around a def or class, PEP 8); between one-line code (fields, constants, prototypes, macros) stacked without blank lines, stacked too (int f(void); int h(void); int g(void);), also after the last stacked field or before the first, and below stacked fields before a method; the end of a body or file, a C++ access label and a comment above the new code get no added blank lines. "before" keeps a #! line and encoding or magic comments first. "after" goes below comment lines right under the target that are indented deeper than it (they end its code), and below a block comment after the target on its last line that goes on over more lines. read_symbol's text is indented as a whole, also when it holds several members (see replace_function). Content is indented in tabs next to a tab-indented target, as for add_method. "before" is refused when other code comes before the target on its first line, "after" when other code follows it on its last line or the line starts with the code the target is part of (class A: x = 1), and empty content is refused, as is the tool in JSON, YAML and TOML files.

delete_symbol

file_path, target, include_leading_comments=True

Delete a function or class definition block (including decorators, a JS/TS export and a C++ template <...> header) and the blank lines that separated it (see Layout-aware deletion above). By default also consumes the contiguous leading comment block above the symbol and its decorators (Godoc, Javadoc /** ... */, # or // comments); pass include_leading_comments=False to keep it. A Ruby =begin document and a Sorbet sig above a method go too, and so do comment lines right below the symbol indented deeper than it. A comment before the symbol on its first line (/** @type {X} */ let x;) counts as a leading comment: by default it goes with the symbol, also when it is a block comment that began on an earlier line and when code follows the symbol on its line (/* c */ let x; let y; minus x is let y;); with include_leading_comments=False it stays, on a line of its own (/** @type {X} */ above the next line). A name declared together with others (int a, *b;, Java int x, y;, C++ auto [x, y] = p;, struct S {...} s;, Go var a, b = 1, 2, JS const a = 1, b = 2;) is refused, naming them, rather than deleted with them: give replace_function the declaration without that name instead. Refused in JSON, YAML and TOML files (use delete_key).

Parameters & signatures

Tool

Parameters

Description

add_parameter

file_path, target, parameter, position="end"

Add a parameter to a function signature (position: "start" or "end"; any other value is refused). A list written one parameter per line (aligned under the first one too) gets the new one on a line of its own at the parameters' indentation, with a trailing comma when the last parameter has one; comments are not parameters and stay on their lines. A list with two parameters on a line gets it inline (a, b, c). An empty list over two lines (def f(\n):) gets the parameter on a line of its own, one level deeper (with a trailing comma in Go, and in Python, JS and TS as the file's other lists have it). A name the function already has is refused (Parameter 'a' already exists in 'f'), names in destructuring patterns and Go groups included; several _ are fine in Go, Java and Ruby. A C or C++ (void) list gets the parameter in place of void (f(void) becomes f(int c); void *p is a parameter). "end" goes before the parameters that must stay last: a Python **kwargs (f(a, **kw) plus b is f(a, b, **kw); after *args or a bare * the new parameter is keyword-only), a JS/TS rest parameter (function f(a, ...rest) plus b is function f(a, b, ...rest)), a Go variadic one (func f(a int, xs ...int) plus b string is func f(a int, b string, xs ...int)), Java varargs (String... xs), the ... of C and C++, a C++ parameter pack (Ts... xs) and a C/C++ macro's GNU named variadic parameter (#define M(a, args...) plus b is #define M(a, b, args...)). On the old C++ form int a... outside a template, the new parameter goes before the ... (int g(int a, int b...)); int... and template functions keep it before the variadic parameter. A C++ int f(...) gets int f(int b, ...) at either position. In Ruby the new parameter goes before the kinds that must come after it, in Ruby's order: required, optional, *splat, required, keyword, **opts/**nil, &blk, ...; so b goes before keyword parameters, b = 1 before the splat, *r before keywords and k: 1 before **opts (def f(a, **opts, &blk) plus b is def f(a, b, **opts, &blk)). In a list written one per line the new parameter gets its own line above them. "start" goes after a TS this parameter or a Java receiver parameter, which must stay first (function f(this: Window, a) plus b is function f(this: Window, b, a)); in a list written one per line, above the first parameter and the comment lines right before it.

remove_parameter

file_path, target, parameter_name

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 (). A Go name of several that share a type goes alone (func f(a, b int) minus a is func f(b int)), the other names keeping their comments; a type-only Go parameter, the receiver and named results are not parameters; a comment after the comma before a last Go name stays. A name bound by a JS/TS or Ruby destructuring parameter goes alone ({a: x, b} minus x is {b}; in an array or Ruby pattern a hole or _ keeps the positions, [a, b] minus a is [, b]), and the parameter goes when it was its only name; a key (a of {a: x}) is not a name. In a C file (.c, .h) removing the last parameter leaves (void), not () (unspecified parameters before C23), and removing the last named parameter before a ... is refused, since f(...) is invalid C before C23; C++ keeps () and allows f(...) (int f(int a, ...) minus a is int f(...)). In Python a bare * goes when only **kwargs would follow it (f(a, *, b) minus b is f(a), f(a, *, b, **kw) minus b is f(a, **kw)), and a / goes when the removed parameter was the only one before it (f(a, /, b) minus a is f(b)). Removing *args leaves a bare * when keyword-only parameters follow it (f(a, *args, b) minus args is f(a, *, b), and f(a, *args, b=1, **kw) gives f(a, *, b=1, **kw)); with nothing or only **kwargs after it, *args just goes (f(a, *args, **kw) gives f(a, **kw)).

Imports & includes

Tool

Parameters

Description

add_import

file_path, import_text

Add an import/from/#include/require line. Skips duplicates. Goes into the group of imports it belongs with, at its sorted place when the group is sorted, else after the group's last import, and after the file's last import when no group fits; a first import goes after the preamble with a blank line around it (see Import placement above). For Go, import "foo" and a spec alone ("foo", alias "foo") both work: the spec goes inside an existing import ( ... ) block, or is written as import "foo" without one.

remove_import

file_path, import_text

Remove a matching import line, compared after stripping surrounding whitespace: pass the exact 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, import type {...} and import D, {...} too). Idempotent. When the names are sorted the new one goes at its sorted place, else at the end. Sorted means plain, case-sensitive order (a single name always is), or else isort's and ruff's order by type: CONSTANT_CASE, then CamelCase, then the rest, each without case (from typing import TYPE_CHECKING, Any, cast plus Dict gives TYPE_CHECKING, Any, Dict, cast); names that fit both use plain order (Any, Dict plus TYPE_CHECKING gives Any, Dict, TYPE_CHECKING). A name sorts by the name it imports (b as bb as b, a TS type B as B). The list keeps its layout: names written one per line in parentheses or braces get the new one on a line of its own at their indentation (aligned under the ( or { when they are), above the next name and the comment lines right above it, or at the end after the last name's line and its comment, with the trailing-comma style kept (from x import (alpha, gamma) one per line plus beta gives alpha, beta, gamma, one per line); a comment heading the list stays first, and a next-line directive (see Import placement above) stays on the name below it. Inline lists, and a Python list without parentheses, stay inline, the new name going before the next one (from x import a, c plus b gives a, b, c; import { a, c } from "m" plus b gives { a, b, c }). For a default or namespace import (import Foo from ..., import * as ns from ...) use add_import with the full line.

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; removing the last name beside a default binding (import D, { a } from "m") is refused, as it would leave empty braces: use remove_import, then add_import the default binding. In parentheses or braces, a name on a line of its own goes with that line and the comment at its end, comments on other lines stay, and a last name without a comma takes the comma before it.

Comments & docstrings

Tool

Parameters

Description

edit_leading_comment

file_path, target, op, comment=""

Edit the contiguous leading-comment block above a named symbol, which is above its decorators/annotations, a JS/TS export and a C++ template <...> header; a symbol that other code comes before on its line has none of its own and is refused. op="add" inserts (refused if the symbol already has a leading comment; the error quotes it, so a following op="replace" can repeat lines worth keeping, such as // eslint-disable-next-line or //go:noinline directives); op="replace" replaces (or inserts if none); op="remove" deletes. comment includes its markers, is re-indented to the symbol, and is required for add and replace (ignored for remove). Works for # / // / /* ... */ / Javadoc /** ... */ and a Ruby =begin document (alone or with # lines beside it, kept at column 0); a Ruby Sorbet sig and a TS function's overload signatures are part of the symbol, so the comment goes above them; a comment line indented deeper than the symbol ends the code above, so op="add" puts the new comment between the two; a /* ... */ that follows code on its line (int x = 1; /* n */) is not taken as a leading comment. In YAML and TOML the target is a key path (a YAML key with no value too) or a TOML [table] by its header's names (tool.uv); a key that does not start its line (inside a flow mapping or inline table) is refused, since the comment line above would belong to another key.

replace_docstring

file_path, target, new_docstring

Replace or insert a Python function/class docstring, or the module's for target="". Python-only. new_docstring must be one Python string literal with its quotes, for every target: bare text, an f-string, a bytes literal or an empty new_docstring is refused (the error says to write it with its quotes, as in '"""Summary."""') and the file is left unchanged. Inserting one into a body on its signature's line (def f(): return 1) is refused: rewrite the body with replace_function_body. A new module docstring goes before the first statement (a from __future__ import stays after it), right below a #! line and an encoding comment, and below a header comment set off by a blank line, which keeps its blank lines; a comment right above the first statement stays with it, below the docstring. One blank line follows the docstring (two before a def or class), and in a file with no statement it goes last. An f-string or bytes literal at the top of the module is no docstring and stays.

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_value

file_path, target, content

Replace the value of an existing key. Targets as for delete_key: JSON/YAML/TOML dotted path from the top level ("editor.fontSize" for a key with dots; keys inside YAML flow mappings and TOML inline tables too, which stay inline); Python DictName.keyExpr; JS/TS VarName.key (a shorthand { key } becomes key: <content>). A YAML key with no value (a:) gets one (a: 3, or a nested block below the key for content of several lines or a block sequence or mapping; a first line that is only |, >-, an &anchor or a !tag stays after the colon). In source files it also replaces the value of a variable, constant or field (Python X = 1 and class attributes, JS/TS const X = 1;, each name of let a = 1, b = 2 alone, and class fields A.x, Go var/const, C/C++ globals, C++ fields and static members, macros, Java fields, Ruby constants and locals, a heredoc's body too): only the value (int x(1); and int y{1}; keep their delimiters), and a declaration without one gets one (let x; becomes let x = 2;, X: int becomes X: int = 2, int g; becomes int g = 2;). Refused: a function, class or type (rewrite it with replace_function), a name that shares one value with others (a = b = 1, Go var c, d = f()), and one that cannot have a value of its own (extern, TS declare, an abstract field, Go var e, g int; for C struct fields and Go constants see the language table).

add_key

file_path, parent_target, key, value

Add a key-value pair to a dict/object/mapping/table. For config, parent_target is a dotted path from the top level (use "" for root; a TOML [table] by its header's names, a."b.c" for [a.'b.c']); YAML flow mappings (nested ones too) and keys with no value and TOML inline tables work too. For JSON and YAML key is one name, dots included. For Python it is the dict variable name; for JS/TS the name of a module-level const/let/var (or export const) object literal, as const / satisfies included. Both may continue into nested literals (CONFIG."db", CONFIG.server). JSON keys are quoted for you, YAML and TOML keys only when they need it (in TOML, dots make a dotted key), JS/TS keys only when they are not valid identifiers, Python keys are key expressions ('"timeout"'). The entry follows the container's layout (one per line, inline, trailing comma), and a key that already exists is refused (use replace_value).

delete_key

file_path, target

Delete a key-value pair. Targets: JSON/YAML/TOML dotted path from the top level ("editor.fontSize" for a key with dots; keys inside YAML flow mappings and TOML inline tables too, cut like JSON entries with their comma); Python DictName.keyExpr; JS/TS VarName.keyName on const/let/var or export const object literals (handles regular pairs, { key } shorthand, methods, and quoted "complex-key"). Python and JS/TS paths may continue into nested literals (CONFIG.server.port). For JSON, Python and JS/TS, the adjacent comma is also removed (an entry on its own line goes with that whole line, including its same-line comment), and deleting the only entry leaves {}; other YAML and TOML keys go with their lines. For an entry of an inline object literal passed as a function argument (foo({ x })), use delete_in_body on the enclosing function.

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 from the top level. The array keeps its layout: inline stays inline ([1, 2, 3]), one element per line gets a new line at the elements' indentation, and a trailing comma is kept; comments are not elements, and the new element's comma goes before a comment (see Layout-aware append_to_array above).

remove_from_array

file_path, target, value_match

Remove the first matching element from a list/array/sequence. value_match is compared with each element's source text after stripping whitespace. Comments are not elements: one on its own line goes with that line and the comment at its end, comments on other lines stay (one above it, or after the element before it, too), the trailing-comma style is kept (a last element with no comma after it takes the comma before it), and removing the only element leaves [] (a YAML block sequence becomes key: [], before a comment on the key's line).

Navigation & reading (read-only)

Tool

Parameters

Description

list_symbols

file_path

Formatted outline of all top-level functions, classes, methods and data with line numbers, one <kind> <name> (line N) per symbol, the name being its target (a symbol's line is that of its first decorator, export, Ruby Sorbet sig or first overload signature). JS/TS exported declarations are listed under their own names, an anonymous default export as default; generators and functions bound to a const/let/var as function, other const names as constant, let/var names as variable and class fields as field, TS abstract classes as class (with their abstract methods), and TS enums, type aliases, interfaces and namespaces as enum, type, interface and namespace. Nested classes are listed under their containers with their members (class A.B, method A.B.m), as are Python module variables and class attributes, Ruby constants and locals, C/C++ fields, macros, typedefs, globals, destructors and operators, Java fields and Go type aliases and variables (see the language table). Of a name a scope declares more than once, the target is listed, once. In C, C++, Java, JS and TS every entry is listed under the target that finds it at the listed line: overloads with their parameter types (method A.m(int) (line 2)), a C++ or Java method beside a field of its name as A.x() (in JS/TS the method is A.x and the field is not listed), a C++ definition outside its class by its qualified name (function A::f), and a tag or type that shares its name with another symbol after its keyword, so the line reads struct struct stat (line 1) or type type User (line 2); an entry no target reaches is left out. In Python and Ruby a name defined again is listed again, and the name targets the first. Names that a pattern binds (a, b = 1, 2, const {a} = o) are not listed, and a declaration of several names (int a, *b;) lists each. Go lists types with their methods, then the names of var and const declarations, then functions.

find_references

file_path, target

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 line N: <source line> in file order, by line and column.

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), from its first byte, so the later lines keep their file indentation: exactly what replace_function and delete_symbol take (with a Go type/var/const, a closure's key, a TS declare, the C typedef or declaration around a struct, a Ruby private, a TS abstract method's ;), ready to be edited and given back to replace_function as is; "interface" returns a class stub (header + fields + method sigs with ..., each member at its indentation relative to the class header and ending as written, with its ; or ,; a C/C++ class closes with };) or a function's signature; "signature" returns signature-only (a method with no body, such as a TS abstract or Java interface method or a C++ A() = default;, is its whole declaration, and a Go struct its header, type A struct). Both start where the full text starts, except that a function's or class's signature leaves out its decorators and a JS/TS export (handler: () =>, RunE: func() error, declare function f(a: number): void, extern "C" int f(int a), private def m(x), typedef struct N, or typedef struct without a tag), and a closure's interface is its signature. A TS overload group and a Python @overload group give the implementation's signature, a Ruby endless method its part before the =. For a Go type, "interface" is its declaration as "full" reads it plus its methods' signatures (type N int has no signature, but its interface lists its methods). Both refuse a variable, constant, field, type alias, macro or C/C++ forward declaration, saying what it is ('X' is a constant, which has no signature ...; read it with depth='full'). A declaration of several names (int a, *b;, Java int x, y;) reads whole for each of them, and a comment after the symbol on its last line is no part of it. For a config key path, "full" (the only depth config files take) returns the key's value (an empty string for a YAML key with no value).

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). 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_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") ("before" goes above the symbol's leading comment)

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 key (config, Python dict, JS/TS object)

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 (a route mount, a middleware call, a key of an inline object-literal argument)

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_function or replace_function_body to change a few lines — use replace_in_body (scoped snippet match) or insert_in_body(at="top" \| "bottom") for appending/prepending. Rewriting the whole function is wasteful and error-prone.

  • Don't use replace_signature to add or remove one parameter — use add_parameter/remove_parameter.

  • Don't use replace_value to add a new key — use add_key. replace_value only updates existing keys, and add_key refuses a key that already exists.

  • Don't use add_import to add a name to an existing from X import … or named import — use add_import_name.

  • Don't guess at target names. Call list_symbols first. Names are case-sensitive and must match exactly.

Escape hatches

  • Deleting several related symbols? Call delete_symbol once per target. There is no delete_group tool: the extra calls cost less than a grouped primitive would.

  • Editing a sub-expression inside a method chain (dropping .references(...) from integer("x").notNull().references(...))? Use replace_in_body with 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 | 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.

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-editor below with the absolute path to the ast-editor folder on your machine (the folder that contains its pyproject.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-mcp

Method 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 uv for "command", not just "uv". GUI-based MCP clients (Claude Desktop, Cursor) don't always inherit your shell PATH, 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/config/mcp_config.json — see Antigravity IDE (uses uvx --from)

Gemini CLI

~/.gemini/settings.json (Global) or .gemini/settings.json (Project)

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 uvx for "command", not a bare "uvx" — Antigravity doesn't inherit your shell PATH. Find it with which uvx (e.g. /opt/homebrew/bin/uvx).

Note: uvx ignores both this server's uv.lock and the [tool.uv] settings in its pyproject.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" in args, 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.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

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*.log

  • Windows: %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 tools
add_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_bodyA
Destructive

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_keyA
Destructive

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_symbolA
Destructive

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_commentA
Destructive

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_referencesA
Read-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_symbolsA
Read-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_importsA
Read-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_symbolA
Read-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_arrayA
Destructive

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_importA
Destructive

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_nameA
Destructive

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_parameterA
Destructive

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_docstringA
Destructive

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_functionA
Destructive

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_bodyA
Destructive

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_bodyA
Destructive

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_signatureA
Destructive

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_valueA
Destructive

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

    A4.4/5.0

    Scored across 28 tools

    Disambiguation5/5

    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.

    Naming Consistency5/5

    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.

    Tool Count2/5

    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.

    Completeness4/5

    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

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      D
      maintenance
      Advanced 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.
      1
      10
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      Enables 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.
      4
      495
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Provides 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.
      37
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      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.
      28
      10
      MIT