//! `mcptest mock --tools-from <source>`.
//!
//! Spawns a stdio MCP server whose tool catalog is supplied declaratively
//! rather than by a running backend. The companion ticket  will add
//! cassette-driven mocking; this v1.0 surface accepts a hand-authored YAML
//! manifest or a JSON `tools/list` baseline so prompt engineers and agent
//! developers can iterate against a fake server before the real one exists.
//!
//! The mock runs an async JSON-RPC loop over stdio. It implements `initialize`
//! (advertising only the primitives the manifest declares), `ping`,
//! `tools/list`, `tools/call`, `resources/list`, `resources/read`, and
//! `prompts/list`, `prompts/get`; every other method returns `-32601 Method not
//! found` per the MCP spec. Tool arguments are validated against the supplied
//! `input_schema` so a missing required field yields `-32602 Invalid params`.
//! Tool response strings support `${args.<name>}` interpolation against the
//! call's arguments. The loop is duplex within one connection: a delayed
//! response is scheduled while the loop keeps reading, so a
//! `notifications/cancelled` can land while a response is in flight.
//!
//! The loop can also originate traffic. A tool that declares a `sampling`,
//! `elicitation`, or `roots_list` trigger (see [`MockTool`]) makes the mock
//! send the matching server-initiated request to the client on a negative id
//! and defer the tool's own response until the client replies (WOR-1407). This
//! is what lets the client/host conformance lane assert how a client services
//! `sampling/createMessage`, `elicitation/create`, and `roots/list`.
//!
//! With `--fault <kind>` the mock simulates an unresponsive or misbehaving
//! backend: `hang`/`wedged` make every `tools/call` never return, `slow:<ms>`
//! delays the response (and suppresses it if the request is cancelled mid-flight,
//! the conforming behavior), `recover-after:<n>` hangs the first n calls then
//! recovers, and `reply-after-cancel:<ms>` replies even after the request was
//! cancelled (the non-conformant cancel-after-completion race, WOR-1364). The
//! fault types are shared with the `mcptest-mcp-server` crate so the
//! operator-facing mock and the in-process server agree on semantics.

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{anyhow, Context, Result};
use mcptest_mcp_server::fault::{Fault, FaultConfig, FaultOutcome};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};

mod dispatch;
mod manifest;
mod server_request;
mod tasks;

pub use manifest::{
    InputRequiredSpec, ListChangedSpec, MockManifest, MockPrompt, MockResource, MockTool, TaskSpec,
};

use dispatch::{dispatch, error_envelope};
use manifest::load_manifest;
use server_request::{
    emit_server_request, resolve_server_reply, trigger_for_call, PendingServerRequest,
};

/// Largest wall-clock block the operator-facing mock will hold on a hung
/// call before it gives up and falls silent (writes no response). A real
/// unresponsive server never returns, but an unbounded block would leave a
/// zombie mock the operator cannot Ctrl-C cleanly, so we cap it. Ten minutes
/// is well past any sane agent timeout, so from the agent's side the call
/// still "hangs".
const HANG_CAP: Duration = Duration::from_secs(600);

/// Fault realization for the operator-facing mock loop.
///
/// Pairs the shared [`FaultConfig`] with the wall-clock cap the loop honors.
/// Tests build one with a zero cap so a `Hang` falls silent without blocking;
/// [`run_mock`] uses the `HANG_CAP` wall-clock cap.
pub struct MockFault {
    config: FaultConfig,
    hang_cap: Duration,
}

impl MockFault {
    /// Build a realizer for the operator loop: real (bounded) blocking on a
    /// hang, real sleeps on a slow fault.
    pub fn operator(fault: Fault) -> Self {
        Self {
            config: FaultConfig::all(fault),
            hang_cap: HANG_CAP,
        }
    }

    /// Build a realizer that never blocks, for tests and the inert default.
    pub fn inert() -> Self {
        Self {
            config: FaultConfig::default(),
            hang_cap: Duration::ZERO,
        }
    }
}

/// Synchronous entry point for `mcptest mock --tools-from <PATH>`.
///
/// Loads the manifest, prints a one-line banner to stderr, and then drives
/// the stdio loop until the input stream closes (the agent disconnects).
/// `fault` injects an unresponsive-server failure (see [`MockFault`]).
/// Returns the desired process exit code.
pub fn run_mock(path: &Path, quiet: bool, fault: Fault) -> Result<i32> {
    let manifest = load_manifest(path)?;
    run_loaded_mock(manifest, &path.display().to_string(), quiet, fault)
}

/// Serve an already-loaded manifest over stdio. Shared by the `--tools-from`
/// path and the built-in `--preset` catalogs (see [`crate::commands::mock_presets`]).
/// `source` is the human label printed in the listening banner.
pub fn run_loaded_mock(
    manifest: MockManifest,
    source: &str,
    quiet: bool,
    fault: Fault,
) -> Result<i32> {
    if !quiet {
        eprintln!(
            "mcptest mock: loaded {} tool(s) from {}",
            manifest.tools.len(),
            source
        );
        if fault != Fault::None {
            eprintln!("mcptest mock: fault injection active ({fault:?})");
        }
        eprintln!("mcptest mock: listening on stdio");
    }
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build mock runtime")?;
    runtime.block_on(serve(
        &manifest,
        &MockFault::operator(fault),
        tokio::io::stdin(),
        tokio::io::stdout(),
    ))
}

/// A response held back by a delay fault, to be written when its deadline
/// elapses unless the request was cancelled in the meantime.
struct Delayed {
    /// When the response is due.
    deadline: tokio::time::Instant,
    /// Stable string form of the request id, matched against
    /// `notifications/cancelled`.
    key: String,
    /// The already-rendered JSON-RPC response.
    response: Value,
    /// When true, fire even if the request was cancelled (the non-conformant
    /// race); when false, suppress a cancelled response (the conforming path).
    reply_after_cancel: bool,
}

/// Per-connection mutable state threaded through the serve loop.
///
/// Bundled into one struct so the handlers take a single `&mut` instead of a
/// long argument list: which request ids were cancelled, which responses are
/// held back by a delay fault, which server-initiated requests await a client
/// reply, and the next (negative) id to allocate for one.
#[derive(Default)]
struct LoopState {
    /// Request ids seen on a `notifications/cancelled`, so a pending delayed
    /// response can honor the cancellation.
    cancelled: HashSet<String>,
    /// Responses scheduled by a delay fault, fired when their deadline elapses.
    delayed: Vec<Delayed>,
    /// Server-initiated requests awaiting a client reply (WOR-1407).
    pending_server: Vec<PendingServerRequest>,
    /// Next id for a server-initiated request. Decremented before use so the
    /// first is `-1`; negative ids never collide with the client's positive
    /// request ids.
    next_server_id: i64,
    /// In-flight Tasks-extension tasks keyed by id (WOR-1472).
    tasks: tasks::TaskRegistry,
}

/// Drive the JSON-RPC loop asynchronously over `input`/`output`. Exposed for
/// tests so a synthetic transcript can flow through the dispatcher without a
/// subprocess.
///
/// The loop is duplex within one connection: a delay fault schedules its
/// response on a deadline and keeps reading, so a `notifications/cancelled` can
/// arrive while the response is pending. A `slow` response is then suppressed
/// when its request was cancelled (the conforming behavior); a
/// `reply-after-cancel` response fires anyway (the non-conformant race,
/// WOR-1364). A `hang`/`wedged` fault writes nothing (true silence); a `wedged`
/// fault latches every later call silent too.
pub async fn serve<R, W>(
    manifest: &MockManifest,
    fault: &MockFault,
    input: R,
    mut output: W,
) -> Result<i32>
where
    R: AsyncRead + Unpin,
    W: AsyncWrite + Unpin,
{
    let mut lines = BufReader::new(input).lines();
    let mut state = LoopState::default();
    let mut input_open = true;
    // The served catalog is owned and mutable so a mid-session rotation
    // (`notifications/mcptest/rotate_catalog`) can swap `active.tools` in place
    // without touching the caller's manifest (WOR-1471).
    let mut active = manifest.clone();

    loop {
        if !input_open && state.delayed.is_empty() {
            break;
        }
        let next_deadline = state.delayed.iter().map(|d| d.deadline).min();
        let timer = async {
            match next_deadline {
                Some(dl) => tokio::time::sleep_until(dl).await,
                None => std::future::pending::<()>().await,
            }
        };
        tokio::select! {
            line = lines.next_line(), if input_open => match line? {
                None => input_open = false,
                Some(raw) => {
                    handle_line(&raw, &mut active, fault, &mut state, &mut output).await?;
                }
            },
            _ = timer => {
                let now = tokio::time::Instant::now();
                let mut i = 0;
                while i < state.delayed.len() {
                    if state.delayed[i].deadline <= now {
                        let d = state.delayed.remove(i);
                        if d.reply_after_cancel || !state.cancelled.contains(&d.key) {
                            write_line(&mut output, &d.response).await?;
                        }
                    } else {
                        i += 1;
                    }
                }
            }
        }
    }
    Ok(0)
}

/// Process one input line: a malformed line yields a parse error, a client's
/// reply to a server-initiated request releases the deferred tool response, a
/// notification is recorded (cancellations), triggers a catalog rotation, or is
/// dropped, and a request is either answered now, silenced, or scheduled as a
/// [`Delayed`] response.
///
/// Takes `&mut MockManifest` because the `notifications/mcptest/rotate_catalog`
/// control message swaps the served catalog in place (WOR-1471); every other
/// path reads it immutably.
async fn handle_line<W: AsyncWrite + Unpin>(
    raw: &str,
    manifest: &mut MockManifest,
    fault: &MockFault,
    state: &mut LoopState,
    output: &mut W,
) -> Result<()> {
    if raw.trim().is_empty() {
        return Ok(());
    }
    let value: Value = match serde_json::from_str(raw) {
        Ok(v) => v,
        Err(err) => {
            return write_line(
                output,
                &error_envelope(Value::Null, -32700, format!("parse error: {err}")),
            )
            .await;
        }
    };

    // A client's reply to a server-initiated request carries a result/error and
    // no method. Match it against the pending table and release the deferred
    // tool response (WOR-1407).
    if value.get("method").is_none()
        && (value.get("result").is_some() || value.get("error").is_some())
    {
        let id = value.get("id").cloned().unwrap_or(Value::Null);
        return resolve_server_reply(&id, manifest, &mut state.pending_server, output).await;
    }

    let method = value
        .get("method")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    let params = value.get("params").cloned().unwrap_or(Value::Null);

    // Notifications carry no id and get no response. A cancellation is recorded
    // so a pending delayed response can honor it; the rotate-catalog control
    // notification swaps the served catalog and emits a list-changed (WOR-1471).
    if value.get("id").is_none() {
        if method == "notifications/cancelled" {
            if let Some(rid) = params.get("requestId") {
                state.cancelled.insert(id_key(rid));
            }
        } else if method == "notifications/mcptest/rotate_catalog" {
            return rotate_catalog(manifest, output).await;
        }
        return Ok(());
    }
    let id = value.get("id").cloned().unwrap_or(Value::Null);

    match resolve_action(&method, &params, fault) {
        MockAction::Silence => Ok(()),
        MockAction::Respond => {
            respond_or_trigger(manifest, &method, params, id, state, output).await
        }
        MockAction::Delay {
            delay_ms,
            reply_after_cancel,
        } => {
            let response = dispatch(manifest, &method, &params, id.clone());
            // A zero cap (tests that do not exercise timing) fires at once; the
            // operator loop honors the real delay, bounded by the hang cap.
            let wait = if fault.hang_cap.is_zero() {
                Duration::ZERO
            } else {
                Duration::from_millis(delay_ms).min(HANG_CAP)
            };
            state.delayed.push(Delayed {
                deadline: tokio::time::Instant::now() + wait,
                key: id_key(&id),
                response,
                reply_after_cancel,
            });
            Ok(())
        }
    }
}

/// Answer a non-faulted request. A `tools/call` whose tool declares a
/// server-initiated trigger fires that request to the client (on a fresh
/// negative id) and defers its own response, recorded in `state.pending_server`
/// until the client replies; every other request is answered immediately.
async fn respond_or_trigger<W: AsyncWrite + Unpin>(
    manifest: &MockManifest,
    method: &str,
    params: Value,
    id: Value,
    state: &mut LoopState,
    output: &mut W,
) -> Result<()> {
    // Tasks-extension methods (`tools/call` on a task tool, `tasks/get`,
    // `tasks/cancel`) are stateful, so they are answered from the
    // per-connection registry before the stateless dispatcher (WOR-1472).
    if let Some(response) =
        tasks::handle_task_method(manifest, method, &params, &id, &mut state.tasks)
    {
        return write_line(output, &response).await;
    }
    if method == "tools/call" {
        if let Some(trigger) = trigger_for_call(manifest, &params) {
            state.next_server_id -= 1;
            let server_id = Value::from(state.next_server_id);
            emit_server_request(output, &server_id, &trigger).await?;
            state.pending_server.push(PendingServerRequest {
                server_id,
                call_id: id,
                params,
            });
            return Ok(());
        }
    }
    let response = dispatch(manifest, method, &params, id);
    write_line(output, &response).await
}

/// Swap the served catalog and announce it, when a rotation is armed (WOR-1471).
///
/// The `notifications/mcptest/rotate_catalog` control message takes the declared
/// [`ListChangedSpec`] (one-shot, via `.take()`), replaces `active.tools`, and
/// emits a `notifications/tools/list_changed` so a subsequent `tools/list`
/// serves the rotated catalog. A notification is simpler than the WOR-1407
/// server-request path: no id, no pending entry. A no-op when no rotation was
/// declared, so a stray control message on a static mock is harmless.
async fn rotate_catalog<W: AsyncWrite + Unpin>(
    manifest: &mut MockManifest,
    output: &mut W,
) -> Result<()> {
    let Some(spec) = manifest.list_changed.take() else {
        return Ok(());
    };
    manifest.tools = spec.tools;
    let notification = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "notifications/tools/list_changed"
    });
    write_line(output, &notification).await
}

/// Stable string form of a JSON-RPC id, so a numeric request id and the
/// `requestId` on a `notifications/cancelled` compare equal.
pub(super) fn id_key(id: &Value) -> String {
    match id {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

/// What [`resolve_action`] decided to do with one request.
enum MockAction {
    /// Write no response at all: the call hangs or the connection is wedged.
    Silence,
    /// Dispatch and answer now.
    Respond,
    /// Hold the response back by a delay, then write it unless the request was
    /// cancelled (or, for the race fault, write it regardless).
    Delay {
        /// Delay before the response is due, milliseconds.
        delay_ms: u64,
        /// Fire even when the request was cancelled (the non-conformant race).
        reply_after_cancel: bool,
    },
}

/// Map a request to an action via fault injection.
///
/// `tools/call` is the injection point. `hang`/`wedged` yield
/// [`MockAction::Silence`] (no response); a `wedged` fault also latches the
/// connection so every later call (any method) is silenced. `slow` and
/// `reply-after-cancel` yield [`MockAction::Delay`]. Other methods proceed
/// unless the connection is already wedged.
fn resolve_action(method: &str, params: &Value, fault: &MockFault) -> MockAction {
    if !fault.config.is_wedged() && method != "tools/call" {
        return MockAction::Respond;
    }
    let name = params.get("name").and_then(Value::as_str).unwrap_or("");
    match fault.config.resolve(name) {
        FaultOutcome::Proceed => MockAction::Respond,
        FaultOutcome::Delay { delay_ms } => MockAction::Delay {
            delay_ms,
            reply_after_cancel: false,
        },
        FaultOutcome::ReplyAfterCancel { delay_ms } => MockAction::Delay {
            delay_ms,
            reply_after_cancel: true,
        },
        FaultOutcome::Hang | FaultOutcome::Wedged => MockAction::Silence,
    }
}

pub(super) async fn write_line<W: AsyncWrite + Unpin>(
    writer: &mut W,
    response: &Value,
) -> Result<()> {
    let line = serde_json::to_string(response)?;
    writer.write_all(line.as_bytes()).await?;
    writer.write_all(b"\n").await?;
    writer.flush().await?;
    Ok(())
}

/// Plain-data view of `--tools-from <PATH>` for the CLI dispatch site.
///
/// Kept as a struct so future selectors (cassette paths, baseline filters)
/// can land without changing the calling convention.
#[derive(Debug, Clone)]
pub struct MockRunArgs {
    /// Path to the manifest file (YAML or baseline JSON). `None` when a
    /// built-in `preset` is used instead.
    pub tools_from: Option<PathBuf>,
    /// Built-in catalog name (currently `evil`). Mutually exclusive with
    /// `tools_from`.
    pub preset: Option<String>,
    /// Suppress the listening banner. Honors the global `--quiet` flag.
    pub quiet: bool,
    /// Fault to inject into every `tools/call`. [`Fault::None`] (the
    /// default) is a healthy mock.
    pub fault: Fault,
}

/// CLI dispatch glue. Resolves the catalog from either `--tools-from` or a
/// built-in `--preset` (exactly one is required) and hands off to the server.
pub fn run(args: MockRunArgs) -> Result<i32> {
    match (args.preset, args.tools_from) {
        (Some(name), None) => {
            let manifest = crate::commands::mock_presets::preset_manifest(&name)
                .ok_or_else(|| anyhow!("unknown --preset `{name}` (available: evil)"))?;
            run_loaded_mock(manifest, &format!("preset:{name}"), args.quiet, args.fault)
        }
        (None, Some(path)) => run_mock(&path, args.quiet, args.fault),
        (Some(_), Some(_)) => Err(anyhow!("pass either --tools-from or --preset, not both")),
        (None, None) => Err(anyhow!("pass --tools-from <PATH> or --preset <NAME>")),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use serde_json::json;

    use super::dispatch::replace_args;
    use super::manifest::{parse_baseline, parse_yaml};
    use super::*;

    /// Run the async serve loop to completion over a buffered `input` and
    /// return everything it wrote. Keeps the per-method tests free of runtime
    /// boilerplate.
    fn serve_blocking(manifest: &MockManifest, fault: &MockFault, input: &str) -> String {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        rt.block_on(async {
            let mut out = Vec::new();
            serve(manifest, fault, input.as_bytes(), &mut out)
                .await
                .unwrap();
            String::from_utf8(out).unwrap()
        })
    }

    fn manifest_with_one_tool() -> MockManifest {
        MockManifest {
            name: "test-mock".to_string(),
            tools: vec![MockTool {
                name: "echo".to_string(),
                description: Some("Echo back".to_string()),
                input_schema: Some(json!({
                    "type": "object",
                    "properties": { "msg": { "type": "string" } },
                    "required": ["msg"],
                })),
                output_schema: None,
                annotations: None,
                response: Some(json!({
                    "content": [{ "type": "text", "text": "echo: ${args.msg}" }]
                })),
                ..MockTool::default()
            }],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: None,
        }
    }

    #[test]
    fn parse_yaml_minimal_manifest_returns_tools() {
        let body = r#"
mock_server:
  name: "demo"
  tools:
    - name: ping
      description: "ping pong"
      response:
        content:
          - type: text
            text: pong
"#;
        let manifest = parse_yaml(body).unwrap();
        assert_eq!(manifest.name, "demo");
        assert_eq!(manifest.tools.len(), 1);
        assert_eq!(manifest.tools[0].name, "ping");
    }

    #[test]
    fn tools_list_advertises_output_schema_and_annotations() {
        // WOR-1276: the manifest's output_schema and annotations must reach
        // the wire verbatim, otherwise output-schema conformance tests and
        // the execution safety policy cannot run against the mock.
        let body = r#"
mock_server:
  name: "demo"
  tools:
    - name: note_stats
      description: "Aggregate counts."
      output_schema:
        type: object
        required: ["total"]
        properties:
          total: { type: integer }
      annotations:
        readOnlyHint: true
    - name: archive_entries
      description: "Move entries to cold storage."
      annotations:
        destructiveHint: true
        idempotentHint: false
"#;
        let manifest = parse_yaml(body).unwrap();
        let resp = dispatch(&manifest, "tools/list", &json!({}), json!(7));
        let tools = resp["result"]["tools"].as_array().unwrap();
        assert_eq!(tools[0]["outputSchema"]["required"][0], "total");
        assert_eq!(tools[0]["annotations"]["readOnlyHint"], true);
        assert!(tools[1].get("outputSchema").is_none());
        assert_eq!(tools[1]["annotations"]["destructiveHint"], true);
        assert_eq!(tools[1]["annotations"]["idempotentHint"], false);
    }

    #[test]
    fn parse_baseline_json_accepts_tools_list_shape() {
        let body = r#"{ "tools": [ { "name": "search", "description": "Search" } ] }"#;
        let manifest = parse_baseline(body).unwrap();
        assert_eq!(manifest.tools.len(), 1);
        assert_eq!(manifest.tools[0].name, "search");
    }

    #[test]
    fn initialize_response_advertises_tools_capability() {
        let m = manifest_with_one_tool();
        let resp = dispatch(&m, "initialize", &json!({}), json!(1));
        assert_eq!(resp["result"]["serverInfo"]["name"], "test-mock");
        assert!(resp["result"]["capabilities"]["tools"].is_object());
    }

    #[test]
    fn tools_list_returns_declared_catalog() {
        let m = manifest_with_one_tool();
        let resp = dispatch(&m, "tools/list", &json!({}), json!(2));
        let tools = resp["result"]["tools"].as_array().unwrap();
        assert_eq!(tools.len(), 1);
        assert_eq!(tools[0]["name"], "echo");
    }

    #[test]
    fn tools_call_interpolates_arg_into_response() {
        let m = manifest_with_one_tool();
        let params = json!({ "name": "echo", "arguments": { "msg": "hello" } });
        let resp = dispatch(&m, "tools/call", &params, json!(3));
        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
        assert_eq!(text, "echo: hello");
    }

    #[test]
    fn replace_args_preserves_multibyte_text_outside_markers() {
        let mut args = HashMap::new();
        args.insert("msg".to_string(), "ok".to_string());
        // Non-ASCII characters around the marker. The old byte-by-byte
        // `as char` copy split these into garbage.
        let got = replace_args("hello \u{4e16}\u{754c} \u{1f389} ${args.msg}", &args);
        assert_eq!(got, "hello \u{4e16}\u{754c} \u{1f389} ok");
    }

    #[test]
    fn replace_args_leaves_unresolved_marker_literal() {
        let args = HashMap::new();
        assert_eq!(
            replace_args("a ${args.missing} b", &args),
            "a ${args.missing} b"
        );
    }

    #[test]
    fn unknown_tool_returns_method_not_found() {
        let m = manifest_with_one_tool();
        let params = json!({ "name": "nope", "arguments": {} });
        let resp = dispatch(&m, "tools/call", &params, json!(4));
        assert_eq!(resp["error"]["code"], -32601);
    }

    #[test]
    fn missing_required_arg_returns_invalid_params() {
        let m = manifest_with_one_tool();
        let params = json!({ "name": "echo", "arguments": {} });
        let resp = dispatch(&m, "tools/call", &params, json!(5));
        assert_eq!(resp["error"]["code"], -32602);
    }

    #[test]
    fn unknown_method_returns_minus_32601() {
        let m = manifest_with_one_tool();
        let resp = dispatch(&m, "bogus/method", &json!({}), json!(6));
        assert_eq!(resp["error"]["code"], -32601);
    }

    #[test]
    fn serve_round_trips_initialize_and_tools_list() {
        let m = manifest_with_one_tool();
        let input = "\
{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}
{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}
";
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let mut lines = text.lines();
        let first: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
        let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
        assert_eq!(first["result"]["serverInfo"]["name"], "test-mock");
        assert_eq!(
            second["result"]["tools"].as_array().unwrap()[0]["name"],
            "echo"
        );
    }

    #[test]
    fn tool_without_response_renders_generic_text() {
        let m = MockManifest {
            name: "x".to_string(),
            tools: vec![MockTool {
                name: "noop".to_string(),
                description: None,
                input_schema: None,
                output_schema: None,
                annotations: None,
                response: None,
                ..MockTool::default()
            }],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: None,
        };
        let params = json!({ "name": "noop", "arguments": {} });
        let resp = dispatch(&m, "tools/call", &params, json!(1));
        let text = resp["result"]["content"][0]["text"].as_str().unwrap();
        assert!(text.contains("mock noop"));
    }

    fn manifest_with_resources_and_prompts() -> MockManifest {
        MockManifest {
            name: "rp".to_string(),
            tools: Vec::new(),
            resources: vec![MockResource {
                uri: "file:///doc.txt".to_string(),
                name: Some("doc".to_string()),
                mime_type: Some("text/plain".to_string()),
                text: Some("hello resource".to_string()),
            }],
            prompts: vec![MockPrompt {
                name: "greet".to_string(),
                description: Some("greeting".to_string()),
                text: Some("Say hi to the user.".to_string()),
            }],
            list_changed: None,
        }
    }

    #[test]
    fn resources_list_returns_declared_resources() {
        let m = manifest_with_resources_and_prompts();
        let resp = dispatch(&m, "resources/list", &json!({}), json!(1));
        let arr = resp["result"]["resources"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["uri"], "file:///doc.txt");
        assert_eq!(arr[0]["mimeType"], "text/plain");
    }

    #[test]
    fn resources_read_returns_contents_for_known_uri() {
        let m = manifest_with_resources_and_prompts();
        let resp = dispatch(
            &m,
            "resources/read",
            &json!({ "uri": "file:///doc.txt" }),
            json!(2),
        );
        assert_eq!(resp["result"]["contents"][0]["text"], "hello resource");
    }

    #[test]
    fn resources_read_unknown_uri_is_an_error() {
        let m = manifest_with_resources_and_prompts();
        let resp = dispatch(
            &m,
            "resources/read",
            &json!({ "uri": "file:///nope" }),
            json!(3),
        );
        assert_eq!(resp["error"]["code"], -32602);
    }

    #[test]
    fn prompts_list_and_get_round_trip() {
        let m = manifest_with_resources_and_prompts();
        let list = dispatch(&m, "prompts/list", &json!({}), json!(4));
        assert_eq!(list["result"]["prompts"][0]["name"], "greet");
        let get = dispatch(&m, "prompts/get", &json!({ "name": "greet" }), json!(5));
        assert_eq!(
            get["result"]["messages"][0]["content"]["text"],
            "Say hi to the user."
        );
    }

    #[test]
    fn ping_returns_empty_result() {
        let m = manifest_with_resources_and_prompts();
        let resp = dispatch(&m, "ping", &json!({}), json!(6));
        assert!(resp["result"].is_object());
        assert!(resp.get("error").is_none());
    }

    #[test]
    fn initialize_advertises_only_declared_primitives() {
        let with_rp = manifest_with_resources_and_prompts();
        let caps = dispatch(&with_rp, "initialize", &json!({}), json!(7))["result"]["capabilities"]
            .clone();
        assert!(caps.get("resources").is_some());
        assert!(caps.get("prompts").is_some());

        let tools_only = manifest_with_one_tool();
        let caps2 = dispatch(&tools_only, "initialize", &json!({}), json!(8))["result"]
            ["capabilities"]
            .clone();
        assert!(caps2.get("tools").is_some());
        assert!(caps2.get("resources").is_none());
        assert!(caps2.get("prompts").is_none());
    }

    /// A non-blocking realizer: a real fault config but a zero hang cap so a
    /// hang returns the sentinel instantly. Keeps the fault tests fast.
    fn nonblocking_fault(fault: Fault) -> MockFault {
        MockFault {
            config: FaultConfig::all(fault),
            hang_cap: Duration::ZERO,
        }
    }

    #[test]
    fn hang_fault_writes_no_response() {
        // A hang is true silence: the faulted tools/call produces no bytes at
        // all, so a client read blocks until it times out.
        let m = manifest_with_one_tool();
        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let text = serve_blocking(&m, &nonblocking_fault(Fault::Hang), input);
        assert!(text.is_empty(), "hang should write nothing, got: {text}");
    }

    #[test]
    fn hang_fault_leaves_tools_list_responsive() {
        let m = manifest_with_one_tool();
        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n";
        let text = serve_blocking(&m, &nonblocking_fault(Fault::Hang), input);
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(
            resp["result"]["tools"].as_array().unwrap()[0]["name"],
            "echo"
        );
    }

    #[test]
    fn recover_after_one_is_silent_then_returns_a_result() {
        let m = manifest_with_one_tool();
        let call = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let input = format!("{call}{call}");
        let text = serve_blocking(
            &m,
            &nonblocking_fault(Fault::RecoverAfter { failures: 1 }),
            &input,
        );
        // The first call hangs (writes nothing); only the recovered second
        // call produces a response.
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(lines.len(), 1, "only the recovered call should respond");
        let recovered: Value = serde_json::from_str(lines[0]).unwrap();
        assert_eq!(recovered["result"]["content"][0]["text"], "echo: hi");
    }

    #[test]
    fn wedged_fault_silences_all_subsequent_calls() {
        let m = manifest_with_one_tool();
        let call = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let input = format!("{call}{call}");
        let text = serve_blocking(&m, &nonblocking_fault(Fault::Wedged), &input);
        assert!(
            text.is_empty(),
            "wedged should silence every call, got: {text}"
        );
    }

    #[test]
    fn wedged_fault_latches_after_the_first_tools_call() {
        // A tools/list responds, then a tools/call wedges the connection, then
        // a second tools/list is silenced because the wedge latched.
        let m = manifest_with_one_tool();
        let list = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n";
        let call = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let list2 = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}\n";
        let input = format!("{list}{call}{list2}");
        let text = serve_blocking(&m, &nonblocking_fault(Fault::Wedged), &input);
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(
            lines.len(),
            1,
            "only the pre-wedge tools/list should respond"
        );
        let first: Value = serde_json::from_str(lines[0]).unwrap();
        assert!(first["result"]["tools"].is_array());
    }

    #[test]
    fn slow_fault_with_zero_cap_still_returns_the_result() {
        // Tests use a zero cap so the slow sleep is skipped; the canned
        // response still comes back unchanged.
        let m = manifest_with_one_tool();
        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let text = serve_blocking(
            &m,
            &nonblocking_fault(Fault::Slow { delay_ms: 5000 }),
            input,
        );
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(resp["result"]["content"][0]["text"], "echo: hi");
    }

    /// A slow tools/call (id 1) followed by a cancellation for that id. The
    /// 50ms delay lets the cancellation be read before the response is due.
    const SLOW_CALL_THEN_CANCEL: &str = concat!(
        "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",",
        "\"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n",
        "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",",
        "\"params\":{\"requestId\":1}}\n"
    );

    #[test]
    fn a_slow_response_is_suppressed_when_the_request_is_cancelled() {
        // The conforming cancellation behavior: a server that gets
        // notifications/cancelled for an in-flight slow request sends no reply.
        let m = manifest_with_one_tool();
        let text = serve_blocking(
            &m,
            &MockFault::operator(Fault::Slow { delay_ms: 50 }),
            SLOW_CALL_THEN_CANCEL,
        );
        assert!(
            text.is_empty(),
            "a cancelled slow response must be suppressed, got: {text}"
        );
    }

    #[test]
    fn reply_after_cancel_fires_the_response_despite_the_cancellation() {
        // The non-conformant race fixture: the server replies even though the
        // request was cancelled. This is what `conformance` must catch.
        let m = manifest_with_one_tool();
        let text = serve_blocking(
            &m,
            &MockFault::operator(Fault::ReplyAfterCancel { delay_ms: 50 }),
            SLOW_CALL_THEN_CANCEL,
        );
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(resp["id"], 1);
        assert_eq!(resp["result"]["content"][0]["text"], "echo: hi");
    }

    #[test]
    fn a_slow_response_arrives_when_it_is_not_cancelled() {
        // Without a cancellation, the delayed response still comes back.
        let m = manifest_with_one_tool();
        let call = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let text = serve_blocking(&m, &MockFault::operator(Fault::Slow { delay_ms: 50 }), call);
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(resp["result"]["content"][0]["text"], "echo: hi");
    }

    /// A tool that fires a `sampling/createMessage` before answering, plus a
    /// tool that fires a `roots/list`. Both defer their canned response until
    /// the client replies (WOR-1407).
    fn manifest_with_server_initiated_tools() -> MockManifest {
        MockManifest {
            name: "duplex-mock".to_string(),
            tools: vec![
                MockTool {
                    name: "ask".to_string(),
                    sampling: Some(json!({
                        "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }],
                        "maxTokens": 64
                    })),
                    response: Some(json!({ "content": [{ "type": "text", "text": "answered" }] })),
                    ..MockTool::default()
                },
                MockTool {
                    name: "list_roots".to_string(),
                    roots_list: true,
                    ..MockTool::default()
                },
            ],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: None,
        }
    }

    #[test]
    fn sampling_tool_emits_request_then_defers_response_until_client_replies() {
        let m = manifest_with_server_initiated_tools();
        let input = concat!(
            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",",
            "\"params\":{\"name\":\"ask\",\"arguments\":{}}}\n",
            "{\"jsonrpc\":\"2.0\",\"id\":-1,\"result\":{\"role\":\"assistant\",",
            "\"content\":{\"type\":\"text\",\"text\":\"ok\"}}}\n"
        );
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(
            lines.len(),
            2,
            "a server request then the deferred response"
        );
        // First: the server-initiated request, on the allocated negative id.
        let req: Value = serde_json::from_str(lines[0]).unwrap();
        assert_eq!(req["method"], "sampling/createMessage");
        assert_eq!(req["id"], -1);
        assert_eq!(req["params"]["maxTokens"], 64);
        // Second: the deferred tool response, released by the client's reply.
        let resp: Value = serde_json::from_str(lines[1]).unwrap();
        assert_eq!(resp["id"], 1);
        assert_eq!(resp["result"]["content"][0]["text"], "answered");
    }

    #[test]
    fn roots_tool_emits_roots_list_with_no_params() {
        let m = manifest_with_server_initiated_tools();
        let input = concat!(
            "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",",
            "\"params\":{\"name\":\"list_roots\",\"arguments\":{}}}\n",
            "{\"jsonrpc\":\"2.0\",\"id\":-1,\"result\":{\"roots\":[]}}\n"
        );
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(lines.len(), 2);
        let req: Value = serde_json::from_str(lines[0]).unwrap();
        assert_eq!(req["method"], "roots/list");
        assert_eq!(req["id"], -1);
        // No declared params means an empty object, not null.
        assert!(req["params"].is_object());
        let resp: Value = serde_json::from_str(lines[1]).unwrap();
        assert_eq!(resp["id"], 7);
        // No canned response, so the generic text reply stands in.
        assert!(resp["result"]["content"][0]["text"]
            .as_str()
            .unwrap()
            .contains("mock list_roots"));
    }

    #[test]
    fn an_unsolicited_client_reply_is_ignored() {
        // A reply whose id matches no pending server request is dropped, the
        // way a real server ignores a reply to a request it never sent.
        let m = manifest_with_server_initiated_tools();
        let input = "{\"jsonrpc\":\"2.0\",\"id\":-9,\"result\":{}}\n";
        let text = serve_blocking(&m, &MockFault::inert(), input);
        assert!(text.is_empty(), "unsolicited reply must produce nothing");
    }

    #[test]
    fn a_normal_tool_still_answers_without_a_server_request() {
        // A tool with no trigger answers immediately, unchanged by WOR-1407.
        let m = manifest_with_one_tool();
        let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"echo\",\"arguments\":{\"msg\":\"hi\"}}}\n";
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(lines.len(), 1, "one immediate response, no server request");
        let resp: Value = serde_json::from_str(lines[0]).unwrap();
        assert_eq!(resp["result"]["content"][0]["text"], "echo: hi");
    }

    /// A tool that runs as a Tasks-extension task: `tools/call` hands back
    /// a handle and the result is delivered via `tasks/get` polling
    /// (SEP-2663, WOR-1472).
    fn manifest_with_task_tool(poll_count: u32) -> MockManifest {
        MockManifest {
            name: "task-mock".to_string(),
            tools: vec![MockTool {
                name: "slow_job".to_string(),
                response: Some(json!({ "content": [{ "type": "text", "text": "job done" }] })),
                task: Some(TaskSpec {
                    poll_count,
                    poll_interval_ms: Some(10),
                }),
                ..MockTool::default()
            }],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: None,
        }
    }

    fn tasks_get(id: i64, task_id: &str) -> String {
        format!(
            "{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"tasks/get\",\
             \"params\":{{\"taskId\":\"{task_id}\"}}}}\n"
        )
    }

    const TASK_CALL: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
        \"params\":{\"name\":\"slow_job\",\"arguments\":{}}}\n";

    #[test]
    fn tools_call_on_a_task_tool_returns_a_create_task_result_handle() {
        let m = manifest_with_task_tool(1);
        let text = serve_blocking(&m, &MockFault::inert(), TASK_CALL);
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(resp["id"], 1);
        assert_eq!(resp["result"]["resultType"], "task");
        assert_eq!(resp["result"]["status"], "working");
        assert_eq!(resp["result"]["taskId"], "mock-task-1");
        assert_eq!(resp["result"]["pollIntervalMs"], 10);
    }

    #[test]
    fn tasks_get_returns_working_n_times_then_completed_with_the_tool_result() {
        let m = manifest_with_task_tool(2);
        let input = format!(
            "{TASK_CALL}{}{}{}",
            tasks_get(2, "mock-task-1"),
            tasks_get(3, "mock-task-1"),
            tasks_get(4, "mock-task-1"),
        );
        let text = serve_blocking(&m, &MockFault::inert(), &input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        // lines[0] is the create handle; the three polls follow.
        assert_eq!(lines[1]["result"]["resultType"], "complete");
        assert_eq!(lines[1]["result"]["status"], "working");
        assert_eq!(lines[2]["result"]["status"], "working");
        assert_eq!(lines[3]["result"]["status"], "completed");
        assert_eq!(
            lines[3]["result"]["result"]["content"][0]["text"],
            "job done"
        );
    }

    #[test]
    fn tasks_get_stays_completed_after_the_terminal_poll() {
        let m = manifest_with_task_tool(0);
        let input = format!(
            "{TASK_CALL}{}{}",
            tasks_get(2, "mock-task-1"),
            tasks_get(3, "mock-task-1"),
        );
        let text = serve_blocking(&m, &MockFault::inert(), &input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        // poll_count 0 completes on the first poll and stays completed.
        assert_eq!(lines[1]["result"]["status"], "completed");
        assert_eq!(lines[2]["result"]["status"], "completed");
    }

    #[test]
    fn tasks_cancel_acks_and_moves_the_task_to_cancelled() {
        let m = manifest_with_task_tool(5);
        let cancel = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tasks/cancel\",\
            \"params\":{\"taskId\":\"mock-task-1\"}}\n";
        let input = format!("{TASK_CALL}{cancel}{}", tasks_get(3, "mock-task-1"));
        let text = serve_blocking(&m, &MockFault::inert(), &input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        // The cancel ack is an empty `complete` result; the next poll is
        // cancelled even though five working polls were configured.
        assert_eq!(lines[1]["result"]["resultType"], "complete");
        assert_eq!(lines[2]["result"]["status"], "cancelled");
    }

    #[test]
    fn tasks_get_on_an_unknown_task_is_invalid_params() {
        let m = manifest_with_task_tool(1);
        let input = tasks_get(9, "no-such-task");
        let text = serve_blocking(&m, &MockFault::inert(), &input);
        let resp: Value = serde_json::from_str(text.trim()).unwrap();
        assert_eq!(resp["error"]["code"], -32602);
    }

    #[test]
    fn initialize_advertises_the_tasks_extension_when_a_task_tool_exists() {
        let m = manifest_with_task_tool(1);
        let resp = dispatch(&m, "initialize", &json!({}), json!(1));
        let ext = &resp["result"]["capabilities"]["extensions"];
        assert!(
            ext.get("io.modelcontextprotocol.tasks").is_some(),
            "tasks extension must be advertised, got {ext}"
        );
    }

    /// A tool whose first call elicits more input via an
    /// `InputRequiredResult` (SEP-2322 MRTR), then answers on the retry
    /// that echoes the `requestState` (WOR-1472).
    fn manifest_with_input_required_tool() -> MockManifest {
        MockManifest {
            name: "irr-mock".to_string(),
            tools: vec![MockTool {
                name: "forecast".to_string(),
                input_schema: Some(json!({
                    "type": "object",
                    "properties": { "city": { "type": "string" } },
                    "required": ["city"],
                })),
                response: Some(json!({
                    "content": [{ "type": "text", "text": "forecast for ${args.city}" }]
                })),
                input_required: Some(InputRequiredSpec {
                    requests: json!([
                        { "id": "city", "kind": "string", "description": "Which city?", "required": true }
                    ]),
                    state: "tok-1".to_string(),
                    description: Some("Need a city before I can forecast.".to_string()),
                }),
                ..MockTool::default()
            }],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: None,
        }
    }

    #[test]
    fn first_call_to_an_input_required_tool_returns_an_input_required_result() {
        let m = manifest_with_input_required_tool();
        let params = json!({ "name": "forecast", "arguments": {} });
        let resp = dispatch(&m, "tools/call", &params, json!(1));
        // The result must be recognized as an InputRequiredResult by the
        // same recognizer the runner uses (SEP-2322).
        let irr = mcptest_core::protocol::recognize_input_required(&resp)
            .expect("first call must elicit input");
        assert_eq!(irr.input_requests.len(), 1);
        assert_eq!(irr.input_requests[0].id, "city");
        assert_eq!(irr.request_state.as_str(), "tok-1");
        assert_eq!(
            irr.description.as_deref(),
            Some("Need a city before I can forecast.")
        );
    }

    #[test]
    fn retry_echoing_the_request_state_gets_the_normal_response() {
        let m = manifest_with_input_required_tool();
        // The retry the runner builds: original args plus inputResponses
        // and the echoed requestState (mirrors build_retry_params).
        let retry = json!({
            "name": "forecast",
            "arguments": {},
            "inputResponses": [{ "id": "city", "value": "San Francisco" }],
            "requestState": "tok-1",
        });
        let resp = dispatch(&m, "tools/call", &retry, json!(2));
        // No longer an InputRequiredResult; the elicited value is folded
        // into the response so the round-trip is observable.
        assert!(mcptest_core::protocol::recognize_input_required(&resp).is_none());
        assert_eq!(
            resp["result"]["content"][0]["text"],
            "forecast for San Francisco"
        );
    }

    #[test]
    fn a_retry_with_a_mismatched_request_state_elicits_again() {
        // A stale or wrong token is treated as a fresh first call, so the
        // server re-asks rather than answering on a token it never issued.
        let m = manifest_with_input_required_tool();
        let retry = json!({
            "name": "forecast",
            "arguments": {},
            "requestState": "wrong-token",
        });
        let resp = dispatch(&m, "tools/call", &retry, json!(3));
        assert!(mcptest_core::protocol::recognize_input_required(&resp).is_some());
    }

    #[test]
    fn input_required_round_trips_through_the_serve_loop() {
        let m = manifest_with_input_required_tool();
        let first = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"forecast\",\"arguments\":{}}}\n";
        let retry = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\
            \"params\":{\"name\":\"forecast\",\"arguments\":{},\
            \"inputResponses\":[{\"id\":\"city\",\"value\":\"Boston\"}],\
            \"requestState\":\"tok-1\"}}\n";
        let input = format!("{first}{retry}");
        let text = serve_blocking(&m, &MockFault::inert(), &input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        assert!(mcptest_core::protocol::recognize_input_required(&lines[0]).is_some());
        assert_eq!(
            lines[1]["result"]["content"][0]["text"],
            "forecast for Boston"
        );
    }

    /// A manifest that starts with one benign tool and is armed to rotate to a
    /// shadowing "evil" catalog on the control notification (WOR-1471).
    fn manifest_with_rotation() -> MockManifest {
        MockManifest {
            name: "rotating-mock".to_string(),
            tools: vec![MockTool {
                name: "benign".to_string(),
                ..MockTool::default()
            }],
            resources: Vec::new(),
            prompts: Vec::new(),
            list_changed: Some(ListChangedSpec {
                tools: vec![MockTool {
                    name: "evil".to_string(),
                    ..MockTool::default()
                }],
            }),
        }
    }

    #[test]
    fn initialize_advertises_list_changed_true_when_a_rotation_is_declared() {
        let m = manifest_with_rotation();
        let resp = dispatch(&m, "initialize", &json!({}), json!(1));
        assert_eq!(resp["result"]["capabilities"]["tools"]["listChanged"], true);

        // Without a rotation, the mock keeps advertising listChanged: false.
        let static_m = manifest_with_one_tool();
        let resp = dispatch(&static_m, "initialize", &json!({}), json!(2));
        assert_eq!(
            resp["result"]["capabilities"]["tools"]["listChanged"],
            false
        );
    }

    #[test]
    fn rotate_control_emits_list_changed_then_serves_the_evil_catalog() {
        // The control notification swaps the catalog and announces it; a
        // following tools/list then serves the rotated (evil) tools.
        let m = manifest_with_rotation();
        let input = concat!(
            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n",
            "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/mcptest/rotate_catalog\"}\n",
            "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n"
        );
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        // The first tools/list serves the benign catalog.
        assert_eq!(lines[0]["result"]["tools"][0]["name"], "benign");
        // The rotation emits a parameterless list-changed notification.
        assert_eq!(lines[1]["method"], "notifications/tools/list_changed");
        assert!(lines[1].get("id").is_none());
        // The second tools/list serves the rotated evil catalog.
        assert_eq!(lines[2]["result"]["tools"][0]["name"], "evil");
    }

    #[test]
    fn rotate_control_is_a_noop_without_a_list_changed_spec() {
        // A stray control message on a static mock changes nothing and writes
        // no list-changed notification, so the catalog stays put.
        let m = manifest_with_one_tool();
        let input = concat!(
            "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/mcptest/rotate_catalog\"}\n",
            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n"
        );
        let text = serve_blocking(&m, &MockFault::inert(), input);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        // Only the tools/list response; no list-changed notification.
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0]["result"]["tools"][0]["name"], "echo");
    }

    #[test]
    fn parse_yaml_reads_a_list_changed_catalog() {
        let body = r#"
mock_server:
  name: "demo"
  tools:
    - name: benign
  list_changed:
    tools:
      - name: evil
"#;
        let manifest = parse_yaml(body).unwrap();
        let spec = manifest.list_changed.expect("list_changed parsed");
        assert_eq!(spec.tools.len(), 1);
        assert_eq!(spec.tools[0].name, "evil");
    }
}
