use crate::verdict::{Decision, Verdict};
pub const FLOW_RULE: &str = "mcp-cross-server";
pub const MAX_PIECES: usize = 256;
pub const SHINGLE_WORDS: usize = 8;
const MIN_SHINGLE_CHARS: usize = 40;
const MIN_TOKEN_CHARS: usize = 20;
const SHINGLE_SAMPLE: u64 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolEffect {
Read,
Act,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ToolHints {
pub read_only: Option<bool>,
pub destructive: Option<bool>,
pub open_world: Option<bool>,
}
impl ToolEffect {
#[must_use]
pub fn classify(hints: &ToolHints, acts_by_shape: bool) -> Self {
match (hints.read_only, hints.destructive, hints.open_world) {
(Some(true), _, _) => Self::Read,
(Some(false), _, _) | (_, Some(true), _) | (_, _, Some(true)) => Self::Act,
_ if acts_by_shape => Self::Act,
_ => Self::Read,
}
}
}
#[must_use]
pub fn cross_server(mut decision: Decision, sources: &[String], sink: &str) -> Decision {
let named: Vec<String> = sources.iter().map(|s| format!("`{s}`")).collect();
let reason = format!(
"this call to MCP server `{sink}` writes or sends text that MCP server {} returned \
earlier in this session: a tool result can carry instructions that steer the agent \
to send data elsewhere, so a person checks the arguments (a server listed under \
`mcp.flow_trusted` is exempt)",
named.join(", ")
);
decision.merge(Decision::single(Verdict::Review, FLOW_RULE, reason));
decision
}
#[must_use]
pub fn pieces(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let mut add = |piece: String, out: &mut Vec<String>| {
if out.len() < MAX_PIECES && seen.insert(piece.clone()) {
out.push(piece);
}
};
for url in urls(text) {
add(format!("url:{url}"), &mut out);
}
for word in text.split(|c: char| c.is_whitespace() || "\"'<>()[]{},;`".contains(c)) {
if let Some(mail) = mail(word) {
add(format!("mail:{mail}"), &mut out);
}
}
for token in text.split(|c: char| !(c.is_ascii_alphanumeric() || "_-+=".contains(c))) {
if is_token(token) {
add(format!("tok:{token}"), &mut out);
}
}
let words: Vec<String> = text
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect();
for run in words.windows(SHINGLE_WORDS) {
let joined = run.join(" ");
if joined.len() >= MIN_SHINGLE_CHARS && fnv1a(&joined).is_multiple_of(SHINGLE_SAMPLE) {
add(format!("words:{joined}"), &mut out);
}
}
out
}
fn urls(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find("://") {
let before = &rest[..at];
let scheme_start = before
.char_indices()
.rev()
.take_while(|(_, c)| c.is_ascii_alphanumeric() || "+-.".contains(*c))
.last()
.map_or(at, |(i, _)| i);
let scheme = &before[scheme_start..];
let after = &rest[at + 3..];
let end = after
.find(|c: char| c.is_whitespace() || "\"'<>`()[]{}\\|^".contains(c))
.unwrap_or(after.len());
let body = &after[..end];
rest = &after[end..];
if scheme.is_empty() || !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
continue;
}
let body = body.split(['?', '#']).next().unwrap_or("");
let (authority, path) = body.split_at(body.find('/').unwrap_or(body.len()));
let host = authority
.rsplit('@')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let path = path.trim_end_matches(['.', ',', ';', ':', '!', '/']);
let host = host.trim_end_matches(['.', ',', ';', ':', '!']);
if host.is_empty() {
continue;
}
found.push(format!("{}://{host}{path}", scheme.to_ascii_lowercase()));
}
found
}
fn mail(word: &str) -> Option<String> {
let word = word.rsplit(':').next().unwrap_or(word);
let word = word.trim_matches(|c: char| ".!?".contains(c));
let (local, domain) = word.split_once('@')?;
let plain = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "._%+-".contains(c))
};
let dotted = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.');
(plain(local) && plain(domain) && dotted && !domain.contains('@'))
.then(|| word.to_ascii_lowercase())
}
fn is_token(token: &str) -> bool {
token.len() >= MIN_TOKEN_CHARS
&& token.chars().any(|c| c.is_ascii_digit())
&& token.chars().any(|c| c.is_ascii_alphabetic())
}
fn fnv1a(text: &str) -> u64 {
text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
})
}
#[cfg(test)]
mod tests;