use serde_json::{Value, json};
use crate::detect::extract::extract_patterns;
use crate::detect::format::{SUPPORTED_FORMATS, resolve_language};
const DEFAULT_MAX_RESULTS: usize = 500;
const MAX_MAX_RESULTS: usize = 5000;
pub(crate) fn definition() -> Value {
json!({
"name": "extract_patterns",
"description": "Find every regular expression in a document — JavaScript and \
TypeScript literals and RegExp constructors, and the call sites \
Python, Rust, Go, Java, Ruby, PHP and C# write a pattern at — with \
1-based line and column and a ReDoS verdict for each. Nothing is \
executed: the verdict comes from the shape of the pattern text. It \
flags dangerous shapes and cannot prove a pattern safe.",
"inputSchema": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "The document text to scan." },
"format": {
"type": "string",
"description": format!(
"Language of the document, so only its own spellings are looked for: \
{}. Common extensions and aliases are accepted. Optional — with no \
language every spelling is looked for, which finds more and \
mistakes a path for a pattern more often.",
SUPPORTED_FORMATS.join(", ")
),
},
"filename": {
"type": "string",
"description": "Filename used to infer the language when `format` is \
absent, e.g. \"validate.py\".",
},
"maxResults": {
"type": "integer",
"minimum": 1,
"maximum": MAX_MAX_RESULTS,
"default": DEFAULT_MAX_RESULTS,
"description": format!(
"Cap on returned patterns (default {DEFAULT_MAX_RESULTS}). \
meta.truncated reports whether any were dropped."
),
},
},
"required": ["content"],
"additionalProperties": false,
},
})
}
pub(crate) fn run(arguments: &Value) -> Result<Value, String> {
let content = arguments
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| "content is required and must be a string".to_string())?;
let max_results = read_max_results(arguments)?;
let format = arguments.get("format").and_then(Value::as_str);
let filename = arguments.get("filename").and_then(Value::as_str);
let language = resolve_language(format, filename);
let mut diagnostics: Vec<Value> = Vec::new();
if language.is_none()
&& let Some(named) = format.or(filename)
{
diagnostics.push(json!({
"severity": "warning",
"code": "unknown-format",
"message": format!(
"no regex spellings are known for {named}, so every spelling was looked for"
),
}));
}
let patterns = match extract_patterns(content, language) {
Ok(patterns) => patterns,
Err(message) => {
diagnostics.push(json!({
"severity": "error",
"code": "incomplete",
"message": message,
}));
Vec::new()
}
};
let mut values: Vec<Value> = patterns
.iter()
.map(|pattern| serde_json::to_value(pattern).expect("a pattern serializes"))
.collect();
let truncated = values.len() > max_results;
values.truncate(max_results);
let count = values.len();
Ok(super::envelope(
"extract_patterns",
&json!({ "patterns": values }),
count,
&diagnostics,
truncated,
))
}
fn read_max_results(arguments: &Value) -> Result<usize, String> {
let Some(raw) = arguments.get("maxResults") else {
return Ok(DEFAULT_MAX_RESULTS);
};
let invalid = "maxResults must be a positive integer".to_string();
let value = raw.as_u64().ok_or(invalid.clone())?;
if value < 1 {
return Err(invalid);
}
Ok(usize::try_from(value)
.unwrap_or(MAX_MAX_RESULTS)
.min(MAX_MAX_RESULTS))
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use super::*;
use crate::detect::corpus::document;
const CASES: &str = include_str!("../../fixtures/mcp-extract-patterns.json");
#[derive(Debug, Deserialize)]
struct Case {
name: String,
file: Option<String>,
content: Option<String>,
arguments: Value,
expected: Option<Value>,
#[serde(rename = "expectedError")]
expected_error: Option<String>,
}
#[test]
fn every_shared_case_answers_identically() {
let cases: Vec<Case> = serde_json::from_str(CASES).expect("the corpus is valid JSON");
assert!(!cases.is_empty(), "the corpus is empty");
for case in cases {
let mut arguments = case.arguments.clone();
let content = case
.file
.as_deref()
.map(document)
.map(str::to_string)
.or(case.content);
if let Some(content) = content {
arguments["content"] = json!(content);
}
match (case.expected, case.expected_error) {
(_, Some(expected)) => {
assert_eq!(
run(&arguments).expect_err(&case.name),
expected,
"{}",
case.name
);
}
(Some(expected), None) => {
assert_eq!(
run(&arguments).expect(&case.name),
expected,
"{}",
case.name
);
}
(None, None) => panic!("{} pins neither a result nor an error", case.name),
}
}
}
#[test]
fn the_tool_name_is_pinned() {
assert_eq!(definition()["name"], "extract_patterns");
}
#[test]
fn the_schema_offers_no_text_to_match_against() {
let definition = definition();
let properties = definition["inputSchema"]["properties"]
.as_object()
.expect("properties");
for absent in ["text", "input", "subject", "timeout"] {
assert!(!properties.contains_key(absent), "{absent} is offered");
}
}
#[test]
fn a_fractional_cap_is_refused() {
let error = run(&json!({ "content": "x", "maxResults": 1.5 })).expect_err("a refusal");
assert_eq!(error, "maxResults must be a positive integer");
}
}