use std::path::{Path, PathBuf};
use fastmcp_core::{Cx, Sha256Digest, sha256_bounded};
use fastmcp_protocol::protocol_policy::{ProtocolEra, ProtocolPolicy};
use crate::Client;
use crate::session::ClientProtocolPlan;
pub const MODERN_ERA_VERSION: &str = "2026-07-28";
pub const LEGACY_ERA_VERSION: &str = "2024-11-05";
pub const UNSUPPORTED_ERA_VERSION: &str = "2025-11-25";
const MAX_TRACE_BYTES: usize = 64 * 1024;
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
pub const LEG_NEG_01_A_EVALUATOR_MANIFEST_V1: &str = concat!(
"LEG-NEG-01-A evaluator manifest v1\n",
"entrypoint fastmcp_client::Client::stdio_with_protocol_plan_with_cx\n",
"transport stdio\n",
"supported-eras 2026-07-28,2024-11-05\n",
"policy-case auto-modern-selected policy=Auto signal=modern-discovery-result eligible=false paired=auto-eligible-correlated-refusal\n",
"policy-case auto-eligible-correlated-refusal policy=Auto signal=correlated-discovery-refusal eligible=true paired=auto-ineligible-uncorrelated-refusal\n",
"policy-case auto-ineligible-uncorrelated-refusal policy=Auto signal=uncorrelated-discovery-refusal eligible=false paired=auto-eligible-correlated-refusal\n",
"policy-case auto-ineligible-recognized-modern-error policy=Auto signal=recognized-modern-error eligible=false paired=auto-eligible-correlated-refusal\n",
"policy-case modern-only-never-falls-back policy=ModernOnly signal=correlated-discovery-refusal eligible=false paired=auto-eligible-correlated-refusal\n",
"policy-case legacy-only-never-probes policy=LegacyOnly signal=no-modern-probe eligible=false paired=auto-eligible-correlated-refusal\n",
"policy-case auto-ineligible-malformed-first-wire policy=Auto signal=malformed-first-wire eligible=false paired=auto-eligible-correlated-refusal\n",
"first-wire auto-modern-selected \"method\":\"server/discover\"\n",
"first-wire auto-eligible-correlated-refusal \"method\":\"server/discover\"\n",
"first-wire auto-ineligible-uncorrelated-refusal \"method\":\"server/discover\"\n",
"first-wire auto-ineligible-recognized-modern-error \"method\":\"server/discover\"\n",
"first-wire modern-only-never-falls-back \"method\":\"server/discover\"\n",
"first-wire legacy-only-never-probes \"method\":\"initialize\"\n",
"first-wire auto-ineligible-malformed-first-wire \"method\":\"server/discover\"\n",
"fallback-first-wire auto-eligible-correlated-refusal \"protocolVersion\":\"2024-11-05\"\n",
"pair auto-eligible-correlated-refusal auto-ineligible-uncorrelated-refusal variable=discovery-refusal-response-id\n",
);
#[must_use]
pub fn leg_neg_01_a_manifest_digest() -> Sha256Digest {
sha256_bounded(
LEG_NEG_01_A_EVALUATOR_MANIFEST_V1.as_bytes(),
MAX_MANIFEST_BYTES,
)
.expect("the fixed LEG-NEG-01 A manifest is within its exact byte bound")
}
#[must_use]
pub fn case_input_digest(case: &StdioClassificationCase) -> Sha256Digest {
let mut preimage = String::new();
preimage.push_str(case.case_id());
preimage.push('\0');
preimage.push_str(policy_token(case.policy()));
preimage.push('\0');
preimage.push_str(case.signal().token());
preimage.push('\0');
preimage.push_str(case.command());
for argument in case.args() {
preimage.push('\0');
preimage.push_str(argument);
}
sha256_bounded(preimage.as_bytes(), MAX_MANIFEST_BYTES)
.expect("a declared case input stays within its exact byte bound")
}
#[must_use]
pub const fn policy_token(policy: ProtocolPolicy) -> &'static str {
match policy {
ProtocolPolicy::Auto => "Auto",
ProtocolPolicy::ModernOnly => "ModernOnly",
ProtocolPolicy::LegacyOnly => "LegacyOnly",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StdioFirstWireSignal {
ModernDiscoveryResult,
CorrelatedDiscoveryRefusal,
UncorrelatedDiscoveryRefusal,
RecognizedModernError,
NoModernProbe,
UnsupportedEraAdvertised,
MalformedFirstWire,
}
impl StdioFirstWireSignal {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::ModernDiscoveryResult => "modern-discovery-result",
Self::CorrelatedDiscoveryRefusal => "correlated-discovery-refusal",
Self::UncorrelatedDiscoveryRefusal => "uncorrelated-discovery-refusal",
Self::RecognizedModernError => "recognized-modern-error",
Self::NoModernProbe => "no-modern-probe",
Self::UnsupportedEraAdvertised => "unsupported-era-advertised",
Self::MalformedFirstWire => "malformed-first-wire",
}
}
#[must_use]
pub const fn is_fallback_eligible(self) -> bool {
match self {
Self::ModernDiscoveryResult => false,
Self::CorrelatedDiscoveryRefusal => true,
Self::UncorrelatedDiscoveryRefusal => false,
Self::RecognizedModernError => false,
Self::NoModernProbe => false,
Self::UnsupportedEraAdvertised => false,
Self::MalformedFirstWire => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StdioClassificationCase {
case_id: String,
policy: ProtocolPolicy,
signal: StdioFirstWireSignal,
command: String,
args: Vec<String>,
trace_path: Option<PathBuf>,
}
impl StdioClassificationCase {
#[must_use]
pub fn new(
case_id: impl Into<String>,
policy: ProtocolPolicy,
signal: StdioFirstWireSignal,
command: impl Into<String>,
args: Vec<String>,
) -> Self {
Self {
case_id: case_id.into(),
policy,
signal,
command: command.into(),
args,
trace_path: None,
}
}
#[must_use]
pub fn with_trace_path(mut self, trace_path: impl Into<PathBuf>) -> Self {
self.trace_path = Some(trace_path.into());
self
}
#[must_use]
pub fn case_id(&self) -> &str {
&self.case_id
}
#[must_use]
pub const fn policy(&self) -> ProtocolPolicy {
self.policy
}
#[must_use]
pub const fn signal(&self) -> StdioFirstWireSignal {
self.signal
}
#[must_use]
pub fn command(&self) -> &str {
&self.command
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
#[must_use]
pub fn trace_path(&self) -> Option<&Path> {
self.trace_path.as_deref()
}
#[must_use]
pub const fn expects_fallback(&self) -> bool {
matches!(self.policy, ProtocolPolicy::Auto) && self.signal.is_fallback_eligible()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceOutcome {
NotBound,
Read,
Unreadable {
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CredentialBoundary {
pub mutations: usize,
pub attached: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StdioClassificationRecord {
pub case_id: String,
pub policy: ProtocolPolicy,
pub transport: &'static str,
pub signal: StdioFirstWireSignal,
pub child_pids: Vec<u32>,
pub first_wire: Vec<String>,
pub unrecognized_trace_records: Vec<String>,
pub trace_outcome: TraceOutcome,
pub selected_era: Option<ProtocolEra>,
pub protocol_version: Option<String>,
pub connected: bool,
pub failure: Option<String>,
pub credential_boundary: CredentialBoundary,
}
impl StdioClassificationRecord {
#[must_use]
pub fn child_generation_count(&self) -> usize {
self.child_pids.len()
}
#[must_use]
pub fn legacy_child_count(&self) -> usize {
self.child_pids.len().saturating_sub(1)
}
#[must_use]
pub fn probe_reaped(&self) -> bool {
match self.child_pids.split_first() {
Some((probe, rest)) => !rest.is_empty() && rest.iter().all(|pid| pid != probe),
None => false,
}
}
}
pub fn evaluate_stdio_case(cx: &Cx, case: &StdioClassificationCase) -> StdioClassificationRecord {
let plan = ClientProtocolPlan::stdio(case.policy());
let args: Vec<&str> = case.args().iter().map(String::as_str).collect();
let outcome = Client::stdio_with_protocol_plan_with_cx(cx.clone(), case.command(), &args, plan);
let (connected, selected_era, protocol_version, failure) = match outcome {
Ok(mut client) => {
let era = client.selected_protocol_era();
let version = client.protocol_version().to_owned();
let _ = client.close();
(true, era, Some(version), None)
}
Err(error) => (false, None, None, Some(error.to_string())),
};
let (child_pids, first_wire, unrecognized_trace_records, trace_outcome) =
read_observation_trace(case.trace_path());
StdioClassificationRecord {
case_id: case.case_id().to_owned(),
policy: case.policy(),
transport: "stdio",
signal: case.signal(),
child_pids,
first_wire,
unrecognized_trace_records,
trace_outcome,
selected_era,
protocol_version,
connected,
failure,
credential_boundary: CredentialBoundary::default(),
}
}
fn read_observation_trace(
trace_path: Option<&Path>,
) -> (Vec<u32>, Vec<String>, Vec<String>, TraceOutcome) {
let Some(path) = trace_path else {
return (Vec::new(), Vec::new(), Vec::new(), TraceOutcome::NotBound);
};
let contents = match std::fs::read(path) {
Ok(bytes) if bytes.len() > MAX_TRACE_BYTES => {
return (
Vec::new(),
Vec::new(),
Vec::new(),
TraceOutcome::Unreadable {
reason: format!(
"observation trace exceeds {MAX_TRACE_BYTES} bytes at {} bytes",
bytes.len()
),
},
);
}
Ok(bytes) => bytes,
Err(error) => {
return (
Vec::new(),
Vec::new(),
Vec::new(),
TraceOutcome::Unreadable {
reason: error.to_string(),
},
);
}
};
let text = match String::from_utf8(contents) {
Ok(text) => text,
Err(error) => {
return (
Vec::new(),
Vec::new(),
Vec::new(),
TraceOutcome::Unreadable {
reason: error.to_string(),
},
);
}
};
let mut child_pids = Vec::new();
let mut first_wire = Vec::new();
let mut unrecognized = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
if let Some(pid) = line.strip_prefix("spawn:") {
match pid.trim().parse::<u32>() {
Ok(pid) => child_pids.push(pid),
Err(_) => unrecognized.push(line.to_owned()),
}
} else if let Some(wire) = line.strip_prefix("wire:") {
first_wire.push(wire.to_owned());
} else {
unrecognized.push(line.to_owned());
}
}
(child_pids, first_wire, unrecognized, TraceOutcome::Read)
}