use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{Value, json};
use super::Envelope;
use crate::extract::{self, Notation, Options, SUPPORTED_FORMATS, resolve_format};
const DEFAULT_MAX_RESULTS: usize = 500;
const MAX_MAX_RESULTS: usize = 5000;
pub(crate) fn definition() -> Value {
json!({
"name": "extract_numbers",
"description": "Extract every numeric value from a document. Parses JSON, YAML, CSV, \
TOML, INI and dotenv, and reads numeric literals in Python, Rust, Go, \
Java, Kotlin, C#, C, C++, JavaScript, TypeScript, SQL and shell — \
including hex, binary, octal, digit separators and type suffixes. \
Anything else is scanned as plain text, so a format is optional. \
Returns each number with the notation it was written in, in document \
order, not its position.",
"inputSchema": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "The document text to scan." },
"format": {
"type": "string",
"enum": SUPPORTED_FORMATS,
"description": "Document format. Optional — an unrecognised or absent \
format scans the text directly.",
},
"filename": {
"type": "string",
"description": "Filename used to infer the format when `format` is absent, \
e.g. \"config.toml\".",
},
"dedupe": {
"type": "boolean",
"default": false,
"description": "Collapse repeated values to their first occurrence.",
},
"maxResults": {
"type": "integer",
"minimum": 1,
"maximum": MAX_MAX_RESULTS,
"default": DEFAULT_MAX_RESULTS,
"description": format!(
"Cap on returned values (default {DEFAULT_MAX_RESULTS}). meta.truncated \
reports whether any were dropped."
),
},
},
"required": ["content"],
"additionalProperties": false,
},
})
}
#[derive(Serialize)]
pub(crate) struct Finding {
value: Box<RawValue>,
notation: Notation,
}
#[derive(Serialize)]
pub(crate) struct Extracted {
numbers: Vec<Finding>,
#[serde(rename = "fileType")]
file_type: &'static str,
}
pub(crate) fn run(arguments: &Value) -> Result<Envelope<Extracted>, 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 = 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": "error", "code": "parse-error", "message": message }))
.into_iter()
.collect();
let mut values: Vec<Finding> = extract::extract(content, format, Options)
.into_iter()
.map(|number| Finding {
value: RawValue::from_string(number.value).expect("a rendered number is valid JSON"),
notation: number.notation,
})
.collect();
if arguments.get("dedupe").and_then(Value::as_bool) == Some(true) {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
values.retain(|finding| seen.insert(finding.value.get().to_string()));
}
let truncated = values.len() > max_results;
values.truncate(max_results);
let count = values.len();
Ok(super::envelope(
"extract_numbers",
Extracted {
numbers: values,
file_type: format,
},
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::extract::FALLBACK_FORMAT;
use crate::extract::corpus::document;
const CASES: &str = include_str!("../../fixtures/mcp-extract-numbers.json");
fn text(arguments: &Value) -> String {
serde_json::to_string(&run(arguments).expect("a result")).expect("an envelope serializes")
}
fn answer(arguments: &Value) -> Value {
serde_json::from_str(&text(arguments)).expect("an envelope is 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);
}
if case
.expected
.as_ref()
.is_some_and(|expected| expected["ok"] == false)
{
let ours = answer(&arguments);
if case.file.as_deref() == Some("mixed-array.toml") {
assert_eq!(ours["ok"], true, "this parser reads TOML 1.0");
assert_eq!(
ours["data"]["numbers"],
json!([
{ "value": 1, "notation": "decimal" },
{ "value": 2.5, "notation": "decimal" },
])
);
} else {
assert_eq!(ours["ok"], false, "{}", case.name);
assert_eq!(
ours["diagnostics"][0]["code"], "parse-error",
"{}",
case.name
);
assert_eq!(ours["data"]["numbers"], json!([]), "{}", case.name);
}
continue;
}
match (case.expected, case.expected_error) {
(_, Some(expected)) => {
assert_eq!(
run(&arguments).err().expect(&case.name),
expected,
"{}",
case.name
);
}
(Some(expected), None) => {
assert_eq!(answer(&arguments), 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_numbers");
}
#[test]
fn the_advertised_enum_matches_the_formats_that_resolve() {
let definition = definition();
let advertised: Vec<String> = definition["inputSchema"]["properties"]["format"]["enum"]
.as_array()
.expect("an enum")
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
assert_eq!(advertised, SUPPORTED_FORMATS);
}
#[test]
fn the_shared_tool_returns_a_number_and_its_notation() {
let result = answer(&json!({ "content": r#"{"a":8080}"#, "format": "json" }));
assert!(result["data"]["numbers"][0]["value"].is_number());
assert_eq!(result["data"]["numbers"][0]["value"], 8080);
assert_eq!(result["data"]["numbers"][0]["notation"], "decimal");
assert!(
result["data"]["numbers"][0].get("line").is_none(),
"the shared tool never carries positions"
);
}
#[test]
fn a_number_keeps_the_token_javascript_would_write() {
let written = text(&json!({ "content": r#"{"a":1e21,"b":1e-7}"#, "format": "json" }));
assert!(
written.contains(r#"{"value":1e+21,"notation":"decimal"}"#),
"{written}"
);
assert!(
written.contains(r#"{"value":1e-7,"notation":"decimal"}"#),
"{written}"
);
}
#[test]
fn a_large_integer_keeps_the_double_and_the_token_the_other_server_writes() {
for format in ["env", "ini", "csv", "json", "unknown", "rust"] {
let content = match format {
"env" => "RATE=123456789012345680000".to_string(),
"ini" => "[s]\nrate = 123456789012345680000".to_string(),
"csv" => "a,123456789012345680000".to_string(),
"json" => r#"{"a":123456789012345680000}"#.to_string(),
"rust" => "let a = 123456789012345680000;".to_string(),
_ => "rate 123456789012345680000".to_string(),
};
let written = text(&json!({ "content": content, "format": format }));
assert!(
written.contains(r#""value":123456789012345680000"#),
"{format}: {written}"
);
}
}
#[test]
fn an_unknown_format_falls_back_rather_than_failing() {
let result = answer(&json!({ "content": "rate 0.0825", "format": "nonsense" }));
assert_eq!(result["data"]["fileType"], FALLBACK_FORMAT);
assert_eq!(result["data"]["numbers"][0]["value"], 0.0825);
}
#[test]
fn a_source_language_is_routed_by_name() {
let result = answer(&json!({
"content": "const MODE: u32 = 0o755;",
"format": "rust",
}));
assert_eq!(result["data"]["fileType"], "rust");
assert_eq!(
result["data"]["numbers"],
json!([{ "value": 493, "notation": "octal" }])
);
}
#[test]
fn a_broken_document_is_an_unsuccessful_envelope() {
let result = answer(&json!({ "content": "{not json", "format": "json" }));
assert_eq!(result["ok"], false);
assert_eq!(result["diagnostics"][0]["code"], "parse-error");
assert_eq!(result["data"]["numbers"], json!([]));
}
#[test]
fn a_fractional_cap_is_refused() {
let error = run(&json!({ "content": "x", "maxResults": 1.5 }))
.err()
.expect("a refusal");
assert_eq!(error, "maxResults must be a positive integer");
}
}