use serde::Serialize;
use serde_json::{Value, json};
use crate::detect::robots::parse_robots_txt;
use crate::detect::url::{normalize_url, validate_url};
pub(super) const DEFAULT_MAX_RESULTS: usize = 500;
pub(super) const MAX_MAX_RESULTS: usize = 5000;
#[derive(Debug, Serialize)]
pub(super) struct Diagnostic {
pub(super) severity: &'static str,
pub(super) code: &'static str,
pub(super) message: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AnalyzeData {
path: String,
allows_crawling: bool,
#[serde(skip_serializing_if = "JsNumber::is_none")]
crawl_delay: JsNumber,
disallowed_paths: Vec<String>,
sitemaps: Vec<String>,
}
#[derive(Debug)]
struct JsNumber(Option<f64>);
impl JsNumber {
fn is_none(&self) -> bool {
self.0.is_none()
}
}
impl Serialize for JsNumber {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
Some(seconds) if seconds.fract() == 0.0 && seconds.is_finite() => {
serializer.serialize_i64(seconds as i64)
}
Some(seconds) => serializer.serialize_f64(seconds),
None => serializer.serialize_none(),
}
}
}
#[derive(Debug, Serialize)]
struct Meta {
tool: &'static str,
count: usize,
truncated: bool,
}
#[derive(Debug, Serialize)]
struct Envelope {
ok: bool,
data: AnalyzeData,
diagnostics: Vec<Diagnostic>,
meta: Meta,
}
pub(super) fn analyze(arguments: &Value) -> Result<Value, String> {
let content = read_string(arguments, "content")?;
let target = read_string(arguments, "path")?;
let max_results = read_max_results(arguments)?;
let (pathname, path_note) = to_pathname(&target);
let diagnostics: Vec<Diagnostic> = path_note
.map(|message| {
vec![Diagnostic {
severity: "warning",
code: "robots",
message,
}]
})
.unwrap_or_default();
let info = parse_robots_txt(&content, &pathname, None);
let total = info.disallowed_paths.len();
let truncated = total > max_results;
let disallowed: Vec<String> = info
.disallowed_paths
.into_iter()
.take(max_results)
.collect();
let envelope = Envelope {
ok: !diagnostics.iter().any(|d| d.severity == "error"),
meta: Meta {
tool: "analyze_robots_txt",
count: disallowed.len(),
truncated,
},
data: AnalyzeData {
path: pathname,
allows_crawling: info.allows_crawling,
crawl_delay: JsNumber(info.crawl_delay),
disallowed_paths: disallowed,
sitemaps: info.sitemaps,
},
diagnostics,
};
Ok(serde_json::to_value(envelope).expect("envelope serializes"))
}
fn to_pathname(target: &str) -> (String, Option<String>) {
let trimmed = target.trim();
if trimmed.starts_with('/') {
return (trimmed.to_string(), None);
}
if trimmed.contains("://") {
let candidate = normalize_url(trimmed);
if validate_url(&candidate)
&& let Ok(parsed) = url::Url::parse(&candidate)
{
return (parsed.path().to_string(), None);
}
}
let pathname = format!("/{}", trimmed.trim_start_matches('/'));
let note = format!(
"`{target}` has no leading slash and no scheme; it was read as the path {pathname}."
);
(pathname, Some(note))
}
fn read_string(arguments: &Value, name: &str) -> Result<String, String> {
arguments
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| format!("{name} is required and must be a string"))
}
fn read_max_results(arguments: &Value) -> Result<usize, String> {
let Some(raw) = arguments.get("maxResults") else {
return Ok(DEFAULT_MAX_RESULTS);
};
if raw.is_null() {
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))
}
pub(super) fn definition() -> Value {
json!({
"name": "analyze_robots_txt",
"description":
"Given the contents of a robots.txt file and a path, report whether the generic \
(User-agent: *) rules permit crawling it, along with the crawl delay, the \
disallowed patterns and any sitemaps. Takes the file contents directly and makes \
no network request of its own — fetch robots.txt with your own HTTP tool and pass \
what it returned.",
"inputSchema": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The contents of the robots.txt file.",
},
"path": {
"type": "string",
"description":
"The path to check, e.g. \"/admin\". A full URL is accepted and reduced \
to its path.",
},
"maxResults": {
"type": "integer",
"minimum": 1,
"maximum": MAX_MAX_RESULTS,
"default": DEFAULT_MAX_RESULTS,
"description": format!(
"Cap on returned disallowed paths (default {DEFAULT_MAX_RESULTS}). \
meta.truncated reports whether any were dropped."
),
},
},
"required": ["content", "path"],
"additionalProperties": false,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
const CASES: &str = include_str!("../../fixtures/mcp-analyze-robots.json");
fn body(file: &str) -> &'static str {
match file {
"simple.txt" => include_str!("../../fixtures/robots/simple.txt"),
"disallow-all.txt" => include_str!("../../fixtures/robots/disallow-all.txt"),
"wildcards.txt" => include_str!("../../fixtures/robots/wildcards.txt"),
"multi-group.txt" => include_str!("../../fixtures/robots/multi-group.txt"),
"agent-specific.txt" => include_str!("../../fixtures/robots/agent-specific.txt"),
"encoded.txt" => include_str!("../../fixtures/robots/encoded.txt"),
other => panic!("fixture body {other} not embedded — add it here"),
}
}
#[test]
fn every_shared_case_reproduces() {
let cases: Value = serde_json::from_str(CASES).expect("fixture JSON");
for case in cases.as_array().expect("array of cases") {
let name = case["name"].as_str().expect("name");
let mut arguments = case["arguments"].clone();
if let Some(file) = case["file"].as_str() {
arguments["content"] = json!(body(file));
}
if case["expected"].is_object() {
let actual = analyze(&arguments)
.unwrap_or_else(|error| panic!("case {name:?} failed: {error}"));
assert_eq!(actual, case["expected"], "case {name:?}");
continue;
}
let error = analyze(&arguments).expect_err(&format!("case {name:?} must fail"));
assert_eq!(
error,
case["expectedError"].as_str().expect("expectedError"),
"case {name:?}"
);
}
}
#[test]
fn an_excessive_cap_is_clamped_not_rejected() {
let arguments = json!({
"content": "User-agent: *\nDisallow: /a\n",
"path": "/b",
"maxResults": 999_999,
});
let result = analyze(&arguments).expect("clamped, not rejected");
assert_eq!(result["meta"]["truncated"], false);
}
#[test]
fn truncation_is_reported_rather_than_hidden() {
use std::fmt::Write as _;
let mut content = String::from("User-agent: *\n");
for i in 0..10 {
let _ = writeln!(content, "Disallow: /p{i}");
}
let arguments = json!({ "content": content, "path": "/x", "maxResults": 3 });
let result = analyze(&arguments).expect("runs");
assert_eq!(result["meta"]["truncated"], true);
assert_eq!(result["meta"]["count"], 3);
assert_eq!(
result["data"]["disallowedPaths"]
.as_array()
.expect("array")
.len(),
3
);
}
#[test]
fn the_schema_matches_the_published_tool() {
let definition = definition();
assert_eq!(definition["name"], "analyze_robots_txt");
assert_eq!(
definition["inputSchema"]["required"],
json!(["content", "path"])
);
assert_eq!(definition["inputSchema"]["additionalProperties"], false);
assert_eq!(
definition["inputSchema"]["properties"]["maxResults"]["default"],
500
);
assert_eq!(
definition["inputSchema"]["properties"]["maxResults"]["maximum"],
5000
);
}
}