use serde_json::{Value, json};
use crate::extract::{self, Class, Kind, SUPPORTED_FORMATS, resolve_format};
const DEFAULT_MAX_RESULTS: usize = 500;
const MAX_MAX_RESULTS: usize = 5000;
pub(crate) fn definition() -> Value {
json!({
"name": "extract_ips",
"description": "Extract every IP address, CIDR block and MAC address from a document, \
normalized and classified. IPv6 is canonicalized per RFC 5952, so \
2001:0db8::0001 and 2001:db8::1 come back as one address rather than \
two. Each finding carries its line, column and — for JSON, YAML, TOML, \
INI, dotenv, CSV and logs — the key it sits under. Text this cannot read \
unambiguously is returned as a named refusal, never as a guess: a \
leading zero (octal or decimal, depending on the resolver), a dotted \
triple that may be a version, a bare integer in an address field. \
Resolves no names, geolocates nothing, opens no sockets.",
"inputSchema": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "The document text to scan." },
"format": {
"type": "string",
"enum": SUPPORTED_FORMATS,
"description": "Document format. Optional — it only decides whether findings \
carry a key path, never whether they are found.",
},
"filename": {
"type": "string",
"description": "Filename used to infer the format when `format` is absent, \
e.g. \"app.yaml\" or \"access.log\".",
},
"kind": {
"type": "array",
"items": { "type": "string", "enum": Kind::ALL },
"description": "Report only these kinds. Refusals are always reported.",
},
"class": {
"type": "array",
"items": { "type": "string", "enum": Class::ALL },
"description": "Report only these classes. Refusals are always reported.",
},
"maxResults": {
"type": "integer",
"minimum": 1,
"maximum": MAX_MAX_RESULTS,
"default": DEFAULT_MAX_RESULTS,
"description": format!(
"Cap on returned findings (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 kinds = filter(arguments, "kind", Kind::parse, &Kind::ALL)?;
let classes = filter(arguments, "class", Class::parse, &Class::ALL)?;
let format = resolve_format(
arguments.get("format").and_then(Value::as_str),
arguments.get("filename").and_then(Value::as_str),
);
let diagnostics: Vec<Value> = extract::parse_error(content, format)
.map(|message| json!({ "severity": "warning", "code": "unparsed", "message": message }))
.into_iter()
.collect();
let mut found: Vec<Value> = extract::find(content, format)
.into_iter()
.filter(|one| one.survives(&kinds, &classes))
.map(|one| serde_json::to_value(one).expect("a finding serializes"))
.collect();
let truncated = found.len() > max_results;
found.truncate(max_results);
let refused = found.iter().filter(|one| !one["refused"].is_null()).count();
let count = found.len();
Ok(super::envelope(
"extract_ips",
&json!({
"addresses": found,
"refused": refused,
"format": format,
}),
count,
&diagnostics,
truncated,
))
}
pub(crate) fn filter<T>(
arguments: &Value,
name: &str,
parse: fn(&str) -> Option<T>,
allowed: &[&str],
) -> Result<Vec<T>, String> {
let allowed = allowed.join(", ");
let Some(value) = arguments.get(name) else {
return Ok(Vec::new());
};
let items = value
.as_array()
.ok_or_else(|| format!("{name} must be an array of names"))?;
items
.iter()
.map(|item| {
let text = item
.as_str()
.ok_or_else(|| format!("{name} must be an array of names"))?;
parse(&text.to_lowercase()).ok_or_else(|| format!("{text} is not a {name}: {allowed}"))
})
.collect()
}
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::extract::FALLBACK_FORMAT;
use crate::extract::corpus::document;
const CASES: &str = include_str!("../../fixtures/mcp-extract-ips.json");
#[derive(Debug, Deserialize)]
struct Case {
name: String,
file: Option<String>,
arguments: Value,
expected: Option<Value>,
#[serde(rename = "expectedError")]
expected_error: Option<String>,
}
#[test]
fn every_pinned_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();
if let Some(file) = case.file.as_deref() {
arguments["content"] = json!(document(file));
}
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_ips");
}
#[test]
fn the_advertised_enums_match_what_resolves() {
let definition = definition();
let properties = &definition["inputSchema"]["properties"];
let names = |value: &Value| -> Vec<String> {
value
.as_array()
.expect("an enum")
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
};
assert_eq!(names(&properties["format"]["enum"]), SUPPORTED_FORMATS);
assert_eq!(names(&properties["kind"]["items"]["enum"]), Kind::ALL);
assert_eq!(names(&properties["class"]["items"]["enum"]), Class::ALL);
}
#[test]
fn the_tool_needs_no_filesystem() {
let result = run(&json!({ "content": "connect 10.0.0.1" })).expect("a result");
assert_eq!(result["data"]["addresses"][0]["normalized"], "10.0.0.1");
assert!(result["data"].get("exists").is_none());
}
#[test]
fn an_unknown_format_falls_back_rather_than_failing() {
let result =
run(&json!({ "content": "bind 2001:db8::1", "format": "nonsense" })).expect("a result");
assert_eq!(result["data"]["format"], FALLBACK_FORMAT);
assert_eq!(result["data"]["addresses"][0]["normalized"], "2001:db8::1");
assert_eq!(result["data"]["addresses"][0]["key"], Value::Null);
}
#[test]
fn a_document_with_no_addresses_is_an_ordinary_result() {
let result = run(&json!({ "content": "no addresses here" })).expect("a result");
assert_eq!(result["ok"], true);
assert_eq!(result["meta"]["count"], 0);
}
#[test]
fn a_broken_document_still_reports_its_addresses() {
let result =
run(&json!({ "content": "{not json 10.0.0.5", "format": "json" })).expect("a result");
assert_eq!(result["ok"], true);
assert_eq!(result["diagnostics"][0]["code"], "unparsed");
assert_eq!(result["data"]["addresses"][0]["text"], "10.0.0.5");
}
#[test]
fn a_filter_narrows_the_answer_and_never_hides_a_refusal() {
let content = "8.8.8.8 10.0.0.1 010.1.1.1";
let result = run(&json!({ "content": content, "class": ["global"] })).expect("a result");
let texts: Vec<&str> = result["data"]["addresses"]
.as_array()
.expect("an array")
.iter()
.filter_map(|one| one["text"].as_str())
.collect();
assert_eq!(texts, ["8.8.8.8", "010.1.1.1"]);
assert_eq!(result["data"]["refused"], 1);
}
#[test]
fn an_unknown_filter_name_is_refused() {
let error = run(&json!({ "content": "x", "kind": ["ipv5"] })).expect_err("a refusal");
assert!(error.contains("ipv4"), "{error}");
}
#[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");
}
#[test]
fn a_truncated_answer_says_so() {
let content = (0..10)
.map(|n| format!("10.0.0.{n}"))
.collect::<Vec<String>>()
.join(" ");
let result = run(&json!({ "content": content, "maxResults": 3 })).expect("a result");
assert_eq!(result["meta"]["count"], 3);
assert_eq!(result["meta"]["truncated"], true);
}
}