use crate::command::run_ast_grep;
use crate::config::Config;
use crate::format::format_matches_as_text;
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
schemars, tool, tool_handler, tool_router,
ErrorData as McpError,
ServerHandler,
};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DumpSyntaxTreeParams {
pub code: String,
pub language: String,
#[serde(default = "default_cst")]
pub format: String,
}
fn default_cst() -> String {
"cst".to_string()
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TestMatchCodeRuleParams {
pub code: String,
pub yaml: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindCodeParams {
pub project_folder: String,
pub pattern: String,
#[serde(default)]
pub language: String,
#[serde(default)]
pub max_results: i32,
#[serde(default = "default_text")]
pub output_format: String,
}
fn default_text() -> String {
"text".to_string()
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindCodeByRuleParams {
pub project_folder: String,
pub yaml: String,
#[serde(default)]
pub max_results: i32,
#[serde(default = "default_text")]
pub output_format: String,
}
#[derive(Clone)]
pub struct AstGrepServer {
config: Config,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl AstGrepServer {
pub fn new(config: Config) -> Self {
Self {
config,
tool_router: Self::tool_router(),
}
}
#[tool(description = "
Dump code's syntax structure or dump a query's pattern structure.
This is useful to discover correct syntax kind and syntax tree structure. Call it when debugging a rule.
The tool requires three arguments: code, language and format. The first two are self-explanatory.
`format` is the output format of the syntax tree.
use `format=cst` to inspect the code's concrete syntax tree structure, useful to debug target code.
use `format=pattern` to inspect how ast-grep interprets a pattern, useful to debug pattern rule.
Internally calls: ast-grep run --pattern <code> --lang <language> --debug-query=<format>
")]
async fn dump_syntax_tree(
&self,
Parameters(params): Parameters<DumpSyntaxTreeParams>,
) -> Result<CallToolResult, McpError> {
let result = run_ast_grep(
"run",
&[
"--pattern".to_string(),
params.code,
"--lang".to_string(),
params.language,
format!("--debug-query={}", params.format),
],
None,
self.config.config_path.as_ref(),
)
.await
.map_err(|e| McpError {
code: ErrorCode(0),
message: e.to_string().into(),
data: None,
})?;
Ok(CallToolResult::success(vec![Content::text(
result.stderr.trim().to_string(),
)]))
}
#[tool(description = "
Test a code against an ast-grep YAML rule.
This is useful to test a rule before using it in a project.
Internally calls: ast-grep scan --inline-rules <yaml> --json --stdin
")]
async fn test_match_code_rule(
&self,
Parameters(params): Parameters<TestMatchCodeRuleParams>,
) -> Result<CallToolResult, McpError> {
let result = run_ast_grep(
"scan",
&[
"--inline-rules".to_string(),
params.yaml,
"--json".to_string(),
"--stdin".to_string(),
],
Some(¶ms.code),
self.config.config_path.as_ref(),
)
.await
.map_err(|e| McpError {
code: ErrorCode(0),
message: e.to_string().into(),
data: None,
})?;
let matches: Vec<Value> = serde_json::from_str(&result.stdout).unwrap_or_else(|_| vec![]);
if matches.is_empty() {
return Err(McpError {
code: ErrorCode(-32603), message: "No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.".to_string().into(),
data: None,
});
}
let json_str = serde_json::to_string_pretty(&matches).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json_str)]))
}
#[tool(description = "
Find code in a project folder that matches the given ast-grep pattern.
Pattern is good for simple and single-AST node result.
For more complex usage, please use YAML by `find_code_by_rule`.
Internally calls: ast-grep run --pattern <pattern> [--json] <project_folder>
Output formats:
- text (default): Compact text format with file:line-range headers and complete match text
Example:
Found 2 matches:
path/to/file.py:10-15
def example_function():
# function body
return result
path/to/file.py:20-22
def another_function():
pass
- json: Full match objects with metadata including ranges, meta-variables, etc.
The max_results parameter limits the number of complete matches returned (not individual lines).
When limited, the header shows \"Found X matches (showing first Y of Z)\".
Example usage:
find_code(pattern=\"class $NAME\", max_results=20) # Returns text format
find_code(pattern=\"class $NAME\", output_format=\"json\") # Returns JSON with metadata
")]
async fn find_code(
&self,
Parameters(params): Parameters<FindCodeParams>,
) -> Result<CallToolResult, McpError> {
if params.output_format != "text" && params.output_format != "json" {
return Err(McpError {
code: ErrorCode(-32602), message: format!("Invalid output_format: {}. Must be 'text' or 'json'.", params.output_format).into(),
data: None,
});
}
let mut args = vec!["--pattern".to_string(), params.pattern];
if !params.language.is_empty() {
args.push("--lang".to_string());
args.push(params.language);
}
args.push("--json".to_string());
args.push(params.project_folder);
let result = run_ast_grep(
"run",
&args,
None,
self.config.config_path.as_ref(),
)
.await
.map_err(|e| McpError {
code: ErrorCode(0),
message: e.to_string().into(),
data: None,
})?;
let stdout = result.stdout.trim();
let matches: Vec<Value> = if stdout.is_empty() {
vec![]
} else {
serde_json::from_str(stdout).unwrap_or_else(|_| vec![])
};
let total_matches = matches.len();
let matches = if params.max_results > 0 && total_matches > params.max_results as usize {
matches[..params.max_results as usize].to_vec()
} else {
matches
};
if params.output_format == "text" {
if matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text("No matches found")]));
}
let text_output = format_matches_as_text(&matches);
let mut header = format!("Found {} matches", matches.len());
if params.max_results > 0 && total_matches > params.max_results as usize {
header = format!("Found {} matches (showing first {} of {})", total_matches, params.max_results, total_matches);
}
Ok(CallToolResult::success(vec![Content::text(format!("{}:\n\n{}", header, text_output))]))
} else {
let json_str = serde_json::to_string_pretty(&matches).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json_str)]))
}
}
#[tool(description = "
Find code using ast-grep's YAML rule in a project folder.
YAML rule is more powerful than simple pattern and can perform complex search like find AST inside/having another AST.
It is a more advanced search tool than the simple `find_code`.
Tip: When using relational rules (inside/has), add `stopBy: end` to ensure complete traversal.
Internally calls: ast-grep scan --inline-rules <yaml> [--json] <project_folder>
Output formats:
- text (default): Compact text format with file:line-range headers and complete match text
Example:
Found 2 matches:
src/models.py:45-52
class UserModel:
def __init__(self):
self.id = None
self.name = None
src/views.py:12
class SimpleView: pass
- json: Full match objects with metadata including ranges, meta-variables, etc.
The max_results parameter limits the number of complete matches returned (not individual lines).
When limited, the header shows \"Found X matches (showing first Y of Z)\".
Example usage:
find_code_by_rule(yaml=\"id: x\\nlanguage: python\\nrule: {pattern: 'class $NAME'}\", max_results=20)
find_code_by_rule(yaml=\"...\", output_format=\"json\") # For full metadata
")]
async fn find_code_by_rule(
&self,
Parameters(params): Parameters<FindCodeByRuleParams>,
) -> Result<CallToolResult, McpError> {
if params.output_format != "text" && params.output_format != "json" {
return Err(McpError {
code: ErrorCode(-32602), message: format!("Invalid output_format: {}. Must be 'text' or 'json'.", params.output_format).into(),
data: None,
});
}
let args = vec!["--inline-rules".to_string(), params.yaml, "--json".to_string(), params.project_folder];
let result = run_ast_grep(
"scan",
&args,
None,
self.config.config_path.as_ref(),
)
.await
.map_err(|e| McpError {
code: ErrorCode(0),
message: e.to_string().into(),
data: None,
})?;
let stdout = result.stdout.trim();
let matches: Vec<Value> = if stdout.is_empty() {
vec![]
} else {
serde_json::from_str(stdout).unwrap_or_else(|_| vec![])
};
let total_matches = matches.len();
let matches = if params.max_results > 0 && total_matches > params.max_results as usize {
matches[..params.max_results as usize].to_vec()
} else {
matches
};
if params.output_format == "text" {
if matches.is_empty() {
return Ok(CallToolResult::success(vec![Content::text("No matches found")]));
}
let text_output = format_matches_as_text(&matches);
let mut header = format!("Found {} matches", matches.len());
if params.max_results > 0 && total_matches > params.max_results as usize {
header = format!("Found {} matches (showing first {} of {})", total_matches, params.max_results, total_matches);
}
Ok(CallToolResult::success(vec![Content::text(format!("{}:\n\n{}", header, text_output))]))
} else {
let json_str = serde_json::to_string_pretty(&matches).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json_str)]))
}
}
}
#[tool_handler]
impl ServerHandler for AstGrepServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
server_info: Implementation {
name: "ast-grep".into(),
version: "0.1.0".into(),
..Default::default()
},
capabilities: ServerCapabilities::builder().enable_tools().build(),
..Default::default()
}
}
}