|
5 | 5 | import sys |
6 | 6 | from typing import Any, List, Literal, Optional |
7 | 7 |
|
| 8 | +import yaml |
8 | 9 | from mcp.server.fastmcp import FastMCP |
9 | 10 | from pydantic import Field |
10 | 11 |
|
@@ -59,168 +60,172 @@ def parse_args_and_get_config(): |
59 | 60 |
|
60 | 61 | DumpFormat = Literal["pattern", "cst", "ast"] |
61 | 62 |
|
62 | | -@mcp.tool() |
63 | | -def dump_syntax_tree( |
64 | | - code: str = Field(description = "The code you need"), |
65 | | - language: str = Field(description = "The language of the code"), |
66 | | - format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = "cst"), |
67 | | -) -> str: |
68 | | - """ |
69 | | - Dump code's syntax structure or dump a query's pattern structure. |
70 | | - This is useful to discover correct syntax kind and syntax tree structure. Call it when debugging a rule. |
71 | | - The tool requires three arguments: code, language and format. The first two are self-explanatory. |
72 | | - `format` is the output format of the syntax tree. |
73 | | - use `format=cst` to inspect the code's concrete syntax tree structure, useful to debug target code. |
74 | | - use `format=pattern` to inspect how ast-grep interprets a pattern, useful to debug pattern rule. |
75 | | -
|
76 | | - Internally calls: ast-grep run --pattern <code> --lang <language> --debug-query=<format> |
77 | | - """ |
78 | | - result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format}"]) |
79 | | - return result.stderr.strip() # type: ignore[no-any-return] |
80 | | - |
81 | | -@mcp.tool() |
82 | | -def test_match_code_rule( |
83 | | - code: str = Field(description="The code to test against the rule"), |
84 | | - yaml: str = Field(description="The ast-grep YAML rule to search. It must have id, language, rule fields."), |
85 | | -) -> List[dict[str, Any]]: |
86 | | - """ |
87 | | - Test a code against an ast-grep YAML rule. |
88 | | - This is useful to test a rule before using it in a project. |
89 | | -
|
90 | | - Internally calls: ast-grep scan --inline-rules <yaml> --json --stdin |
91 | | - """ |
92 | | - result = run_ast_grep("scan", ["--inline-rules", yaml, "--json", "--stdin"], input_text = code) |
93 | | - matches = json.loads(result.stdout.strip()) |
94 | | - if not matches: |
95 | | - raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.") |
96 | | - return matches # type: ignore[no-any-return] |
97 | | - |
98 | | -@mcp.tool() |
99 | | -def find_code( |
100 | | - project_folder: str = Field(description="The absolute path to the project folder. It must be absolute path."), |
101 | | - pattern: str = Field(description="The ast-grep pattern to search for. Note, the pattern must have valid AST structure."), |
102 | | - language: str = Field(description="The language of the query", default=""), |
103 | | - max_results: Optional[int] = Field(default=None, description="Maximum results to return"), |
104 | | - output_format: str = Field(default="text", description="'text' or 'json'"), |
105 | | -) -> str | List[dict[str, Any]]: |
106 | | - """ |
107 | | - Find code in a project folder that matches the given ast-grep pattern. |
108 | | - Pattern is good for simple and single-AST node result. |
109 | | - For more complex usage, please use YAML by `find_code_by_rule`. |
110 | | -
|
111 | | - Internally calls: ast-grep run --pattern <pattern> [--json] <project_folder> |
112 | | -
|
113 | | - Output formats: |
114 | | - - text (default): Compact text format with file:line-range headers and complete match text |
115 | | - Example: |
116 | | - Found 2 matches: |
117 | | -
|
118 | | - path/to/file.py:10-15 |
119 | | - def example_function(): |
120 | | - # function body |
121 | | - return result |
122 | | -
|
123 | | - path/to/file.py:20-22 |
124 | | - def another_function(): |
125 | | - pass |
126 | | -
|
127 | | - - json: Full match objects with metadata including ranges, meta-variables, etc. |
128 | | -
|
129 | | - The max_results parameter limits the number of complete matches returned (not individual lines). |
130 | | - When limited, the header shows "Found X matches (showing first Y of Z)". |
131 | | -
|
132 | | - Example usage: |
133 | | - find_code(pattern="class $NAME", max_results=20) # Returns text format |
134 | | - find_code(pattern="class $NAME", output_format="json") # Returns JSON with metadata |
135 | | - """ |
136 | | - if output_format not in ["text", "json"]: |
137 | | - raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") |
138 | | - |
139 | | - args = ["--pattern", pattern] |
140 | | - if language: |
141 | | - args.extend(["--lang", language]) |
142 | | - |
143 | | - # Always get JSON internally for accurate match limiting |
144 | | - result = run_ast_grep("run", args + ["--json", project_folder]) |
145 | | - matches = json.loads(result.stdout.strip() or "[]") |
146 | | - |
147 | | - # Apply max_results limit to complete matches |
148 | | - total_matches = len(matches) |
149 | | - if max_results is not None and total_matches > max_results: |
150 | | - matches = matches[:max_results] |
151 | | - |
152 | | - if output_format == "text": |
| 63 | +def register_mcp_tools() -> None: |
| 64 | + |
| 65 | + @mcp.tool() |
| 66 | + def dump_syntax_tree( |
| 67 | + code: str = Field(description = "The code you need"), |
| 68 | + language: str = Field(description = f"The language of the code. Supported: {', '.join(get_supported_languages())}"), |
| 69 | + format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = "cst"), |
| 70 | + ) -> str: |
| 71 | + """ |
| 72 | + Dump code's syntax structure or dump a query's pattern structure. |
| 73 | + This is useful to discover correct syntax kind and syntax tree structure. Call it when debugging a rule. |
| 74 | + The tool requires three arguments: code, language and format. The first two are self-explanatory. |
| 75 | + `format` is the output format of the syntax tree. |
| 76 | + use `format=cst` to inspect the code's concrete syntax tree structure, useful to debug target code. |
| 77 | + use `format=pattern` to inspect how ast-grep interprets a pattern, useful to debug pattern rule. |
| 78 | +
|
| 79 | + Internally calls: ast-grep run --pattern <code> --lang <language> --debug-query=<format> |
| 80 | + """ |
| 81 | + result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format}"]) |
| 82 | + return result.stderr.strip() # type: ignore[no-any-return] |
| 83 | + |
| 84 | + @mcp.tool() |
| 85 | + def test_match_code_rule( |
| 86 | + code: str = Field(description = "The code to test against the rule"), |
| 87 | + yaml: str = Field(description = "The ast-grep YAML rule to search. It must have id, language, rule fields."), |
| 88 | + ) -> List[dict[str, Any]]: |
| 89 | + """ |
| 90 | + Test a code against an ast-grep YAML rule. |
| 91 | + This is useful to test a rule before using it in a project. |
| 92 | +
|
| 93 | + Internally calls: ast-grep scan --inline-rules <yaml> --json --stdin |
| 94 | + """ |
| 95 | + result = run_ast_grep("scan", ["--inline-rules", yaml, "--json", "--stdin"], input_text = code) |
| 96 | + matches = json.loads(result.stdout.strip()) |
153 | 97 | if not matches: |
154 | | - return "No matches found" |
155 | | - text_output = format_matches_as_text(matches) |
156 | | - header = f"Found {len(matches)} matches" |
157 | | - if max_results is not None and total_matches > max_results: |
158 | | - header += f" (showing first {max_results} of {total_matches})" |
159 | | - return header + ":\n\n" + text_output |
160 | | - return matches # type: ignore[no-any-return] |
161 | | - |
162 | | -@mcp.tool() |
163 | | -def find_code_by_rule( |
164 | | - project_folder: str = Field(description="The absolute path to the project folder. It must be absolute path."), |
165 | | - yaml: str = Field(description="The ast-grep YAML rule to search. It must have id, language, rule fields."), |
166 | | - max_results: Optional[int] = Field(default=None, description="Maximum results to return"), |
167 | | - output_format: str = Field(default="text", description="'text' or 'json'"), |
| 98 | + raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.") |
| 99 | + return matches # type: ignore[no-any-return] |
| 100 | + |
| 101 | + @mcp.tool() |
| 102 | + def find_code( |
| 103 | + project_folder: str = Field(description = "The absolute path to the project folder. It must be absolute path."), |
| 104 | + pattern: str = Field(description = "The ast-grep pattern to search for. Note, the pattern must have valid AST structure."), |
| 105 | + language: str = Field(description = f"The language of the code. Supported: {', '.join(get_supported_languages())}." |
| 106 | + "If not specified, will be auto-detected based on file extensions.", default = ""), |
| 107 | + max_results: Optional[int] = Field(default = None, description = "Maximum results to return"), |
| 108 | + output_format: str = Field(default = "text", description = "'text' or 'json'"), |
168 | 109 | ) -> str | List[dict[str, Any]]: |
169 | | - """ |
170 | | - Find code using ast-grep's YAML rule in a project folder. |
171 | | - YAML rule is more powerful than simple pattern and can perform complex search like find AST inside/having another AST. |
172 | | - It is a more advanced search tool than the simple `find_code`. |
| 110 | + """ |
| 111 | + Find code in a project folder that matches the given ast-grep pattern. |
| 112 | + Pattern is good for simple and single-AST node result. |
| 113 | + For more complex usage, please use YAML by `find_code_by_rule`. |
173 | 114 |
|
174 | | - Tip: When using relational rules (inside/has), add `stopBy: end` to ensure complete traversal. |
| 115 | + Internally calls: ast-grep run --pattern <pattern> [--json] <project_folder> |
175 | 116 |
|
176 | | - Internally calls: ast-grep scan --inline-rules <yaml> [--json] <project_folder> |
| 117 | + Output formats: |
| 118 | + - text (default): Compact text format with file:line-range headers and complete match text |
| 119 | + Example: |
| 120 | + Found 2 matches: |
177 | 121 |
|
178 | | - Output formats: |
179 | | - - text (default): Compact text format with file:line-range headers and complete match text |
180 | | - Example: |
181 | | - Found 2 matches: |
| 122 | + path/to/file.py:10-15 |
| 123 | + def example_function(): |
| 124 | + # function body |
| 125 | + return result |
182 | 126 |
|
183 | | - src/models.py:45-52 |
184 | | - class UserModel: |
185 | | - def __init__(self): |
186 | | - self.id = None |
187 | | - self.name = None |
| 127 | + path/to/file.py:20-22 |
| 128 | + def another_function(): |
| 129 | + pass |
188 | 130 |
|
189 | | - src/views.py:12 |
190 | | - class SimpleView: pass |
| 131 | + - json: Full match objects with metadata including ranges, meta-variables, etc. |
191 | 132 |
|
192 | | - - json: Full match objects with metadata including ranges, meta-variables, etc. |
| 133 | + The max_results parameter limits the number of complete matches returned (not individual lines). |
| 134 | + When limited, the header shows "Found X matches (showing first Y of Z)". |
193 | 135 |
|
194 | | - The max_results parameter limits the number of complete matches returned (not individual lines). |
195 | | - When limited, the header shows "Found X matches (showing first Y of Z)". |
| 136 | + Example usage: |
| 137 | + find_code(pattern="class $NAME", max_results=20) # Returns text format |
| 138 | + find_code(pattern="class $NAME", output_format="json") # Returns JSON with metadata |
| 139 | + """ |
| 140 | + if output_format not in ["text", "json"]: |
| 141 | + raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") |
196 | 142 |
|
197 | | - Example usage: |
198 | | - find_code_by_rule(yaml="id: x\\nlanguage: python\\nrule: {pattern: 'class $NAME'}", max_results=20) |
199 | | - find_code_by_rule(yaml="...", output_format="json") # For full metadata |
200 | | - """ |
201 | | - if output_format not in ["text", "json"]: |
202 | | - raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") |
| 143 | + args = ["--pattern", pattern] |
| 144 | + if language: |
| 145 | + args.extend(["--lang", language]) |
203 | 146 |
|
204 | | - args = ["--inline-rules", yaml] |
| 147 | + # Always get JSON internally for accurate match limiting |
| 148 | + result = run_ast_grep("run", args + ["--json", project_folder]) |
| 149 | + matches = json.loads(result.stdout.strip() or "[]") |
205 | 150 |
|
206 | | - # Always get JSON internally for accurate match limiting |
207 | | - result = run_ast_grep("scan", args + ["--json", project_folder]) |
208 | | - matches = json.loads(result.stdout.strip() or "[]") |
| 151 | + # Apply max_results limit to complete matches |
| 152 | + total_matches = len(matches) |
| 153 | + if max_results is not None and total_matches > max_results: |
| 154 | + matches = matches[:max_results] |
| 155 | + |
| 156 | + if output_format == "text": |
| 157 | + if not matches: |
| 158 | + return "No matches found" |
| 159 | + text_output = format_matches_as_text(matches) |
| 160 | + header = f"Found {len(matches)} matches" |
| 161 | + if max_results is not None and total_matches > max_results: |
| 162 | + header += f" (showing first {max_results} of {total_matches})" |
| 163 | + return header + ":\n\n" + text_output |
| 164 | + return matches # type: ignore[no-any-return] |
| 165 | + |
| 166 | + @mcp.tool() |
| 167 | + def find_code_by_rule( |
| 168 | + project_folder: str = Field(description = "The absolute path to the project folder. It must be absolute path."), |
| 169 | + yaml: str = Field(description = "The ast-grep YAML rule to search. It must have id, language, rule fields."), |
| 170 | + max_results: Optional[int] = Field(default = None, description = "Maximum results to return"), |
| 171 | + output_format: str = Field(default = "text", description = "'text' or 'json'"), |
| 172 | + ) -> str | List[dict[str, Any]]: |
| 173 | + """ |
| 174 | + Find code using ast-grep's YAML rule in a project folder. |
| 175 | + YAML rule is more powerful than simple pattern and can perform complex search like find AST inside/having another AST. |
| 176 | + It is a more advanced search tool than the simple `find_code`. |
| 177 | +
|
| 178 | + Tip: When using relational rules (inside/has), add `stopBy: end` to ensure complete traversal. |
| 179 | +
|
| 180 | + Internally calls: ast-grep scan --inline-rules <yaml> [--json] <project_folder> |
| 181 | +
|
| 182 | + Output formats: |
| 183 | + - text (default): Compact text format with file:line-range headers and complete match text |
| 184 | + Example: |
| 185 | + Found 2 matches: |
| 186 | +
|
| 187 | + src/models.py:45-52 |
| 188 | + class UserModel: |
| 189 | + def __init__(self): |
| 190 | + self.id = None |
| 191 | + self.name = None |
| 192 | +
|
| 193 | + src/views.py:12 |
| 194 | + class SimpleView: pass |
| 195 | +
|
| 196 | + - json: Full match objects with metadata including ranges, meta-variables, etc. |
| 197 | +
|
| 198 | + The max_results parameter limits the number of complete matches returned (not individual lines). |
| 199 | + When limited, the header shows "Found X matches (showing first Y of Z)". |
| 200 | +
|
| 201 | + Example usage: |
| 202 | + find_code_by_rule(yaml="id: x\\nlanguage: python\\nrule: {pattern: 'class $NAME'}", max_results=20) |
| 203 | + find_code_by_rule(yaml="...", output_format="json") # For full metadata |
| 204 | + """ |
| 205 | + if output_format not in ["text", "json"]: |
| 206 | + raise ValueError(f"Invalid output_format: {output_format}. Must be 'text' or 'json'.") |
| 207 | + |
| 208 | + args = ["--inline-rules", yaml] |
| 209 | + |
| 210 | + # Always get JSON internally for accurate match limiting |
| 211 | + result = run_ast_grep("scan", args + ["--json", project_folder]) |
| 212 | + matches = json.loads(result.stdout.strip() or "[]") |
| 213 | + |
| 214 | + # Apply max_results limit to complete matches |
| 215 | + total_matches = len(matches) |
| 216 | + if max_results is not None and total_matches > max_results: |
| 217 | + matches = matches[:max_results] |
209 | 218 |
|
210 | | - # Apply max_results limit to complete matches |
211 | | - total_matches = len(matches) |
212 | | - if max_results is not None and total_matches > max_results: |
213 | | - matches = matches[:max_results] |
| 219 | + if output_format == "text": |
| 220 | + if not matches: |
| 221 | + return "No matches found" |
| 222 | + text_output = format_matches_as_text(matches) |
| 223 | + header = f"Found {len(matches)} matches" |
| 224 | + if max_results is not None and total_matches > max_results: |
| 225 | + header += f" (showing first {max_results} of {total_matches})" |
| 226 | + return header + ":\n\n" + text_output |
| 227 | + return matches # type: ignore[no-any-return] |
214 | 228 |
|
215 | | - if output_format == "text": |
216 | | - if not matches: |
217 | | - return "No matches found" |
218 | | - text_output = format_matches_as_text(matches) |
219 | | - header = f"Found {len(matches)} matches" |
220 | | - if max_results is not None and total_matches > max_results: |
221 | | - header += f" (showing first {max_results} of {total_matches})" |
222 | | - return header + ":\n\n" + text_output |
223 | | - return matches # type: ignore[no-any-return] |
224 | 229 |
|
225 | 230 | def format_matches_as_text(matches: List[dict]) -> str: |
226 | 231 | """Convert JSON matches to LLM-friendly text format. |
@@ -248,6 +253,29 @@ def format_matches_as_text(matches: List[dict]) -> str: |
248 | 253 |
|
249 | 254 | return '\n\n'.join(output_blocks) |
250 | 255 |
|
| 256 | +def get_supported_languages() -> List[str]: |
| 257 | + """Get all supported languages as a field description string.""" |
| 258 | + languages = [ # https://ast-grep.github.io/reference/languages.html |
| 259 | + "bash", "c", "cpp", "csharp", "css", "elixir", "go", "haskell", |
| 260 | + "html", "java", "javascript", "json", "jsx", "kotlin", "lua", |
| 261 | + "nix", "php", "python", "ruby", "rust", "scala", "solidity", |
| 262 | + "swift", "tsx", "typescript", "yaml" |
| 263 | + ] |
| 264 | + |
| 265 | + # Check for custom languages in config file |
| 266 | + # https://ast-grep.github.io/advanced/custom-language.html#register-language-in-sgconfig-yml |
| 267 | + if CONFIG_PATH and os.path.exists(CONFIG_PATH): |
| 268 | + try: |
| 269 | + with open(CONFIG_PATH, 'r') as f: |
| 270 | + config = yaml.safe_load(f) |
| 271 | + if config and 'customLanguages' in config: |
| 272 | + custom_langs = list(config['customLanguages'].keys()) |
| 273 | + languages += custom_langs |
| 274 | + except Exception: |
| 275 | + pass |
| 276 | + |
| 277 | + return sorted(set(languages)) |
| 278 | + |
251 | 279 | def run_command(args: List[str], input_text: Optional[str] = None) -> subprocess.CompletedProcess: |
252 | 280 | try: |
253 | 281 | # On Windows, if ast-grep is installed via npm, it's a batch file |
@@ -281,7 +309,8 @@ def run_mcp_server() -> None: |
281 | 309 | Run the MCP server. |
282 | 310 | This function is used to start the MCP server when this script is run directly. |
283 | 311 | """ |
284 | | - parse_args_and_get_config() |
| 312 | + parse_args_and_get_config() # sets CONFIG_PATH |
| 313 | + register_mcp_tools() # tools defined *after* CONFIG_PATH is known |
285 | 314 | mcp.run(transport="stdio") |
286 | 315 |
|
287 | 316 | if __name__ == "__main__": |
|
0 commit comments