//! Draft-test suggestions for uncovered surface.
//!
//! A coverage report that only says "tool X untested" leaves the next
//! action to the reader. This module turns the gaps in a
//! [`CoverageReport`] into paste-ready YAML drafts: a happy-path test for
//! an uncovered tool, a test that sets a specific uncovered optional
//! argument, and a missing-required-rejection case for an uncovered error
//! path. Argument values come from the shared schema-aware synthesis in
//! [`crate::stub_gen`], so a draft here and a `generate suite` stub agree
//! about what a valid call looks like. Drafts are additive: names are
//! derived per gap and deduplicated against the names already present in
//! the analyzed run, so merging never clobbers a hand-authored test.

use std::collections::{BTreeMap, BTreeSet};

use serde::Serialize;
use serde_json::{json, Map, Value};

use super::report::CoverageReport;
use crate::stub_gen::{required_properties, synthesize_args, synthesize_value};

/// Comment placed on the first line of every draft so a reader (or a
/// later cleanup pass) can tell suggested tests from hand-authored ones.
pub const SUGGESTION_MARKER: &str = "# suggested by mcptest coverage";

/// Which coverage gap a draft closes. Serialized in snake_case so the
/// `get_coverage` verb and the CLI JSON output agree on the wire strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionKind {
    /// The tool was never called by the analyzed suite.
    Tool,
    /// A specific optional argument of a called tool was never set.
    Arg,
    /// A declared error fixture never fired.
    ErrorPath,
}

/// One draft test, carried both as structured fields (so an agent can
/// filter by kind or tool) and as the rendered YAML snippet.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SuggestionItem {
    /// Which gap this draft closes.
    pub kind: SuggestionKind,
    /// Tool the draft calls.
    pub tool: String,
    /// The uncovered argument, present only for [`SuggestionKind::Arg`].
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arg: Option<String>,
    /// The draft as a YAML list entry (marker comment included), indented
    /// to sit directly under a suite's `tools:` key.
    pub yaml: String,
}

/// The full suggestion set for one coverage report.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Suggestions {
    /// One entry per draft, in report order: tools, then args, then
    /// error paths.
    pub items: Vec<SuggestionItem>,
}

impl Suggestions {
    /// True when the analyzed surface left nothing to suggest.
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// All drafts as one document under a `tools:` key, ready to paste
    /// into (or merge with) a suite. Empty string when there are no
    /// drafts, so callers can skip printing an empty block.
    pub fn combined_yaml(&self) -> String {
        if self.items.is_empty() {
            return String::new();
        }
        let mut out = String::from("tools:\n");
        for item in &self.items {
            out.push_str(&item.yaml);
        }
        out
    }

    /// The wire shape the `get_coverage` verb and `coverage --format json`
    /// embed: `{"yaml": "<combined>", "items": [...]}`.
    pub fn to_json(&self) -> Value {
        json!({ "yaml": self.combined_yaml(), "items": self.items })
    }
}

/// Caller-supplied context the builder cannot derive from the report.
///
/// `error_path_tools` maps a fixture name to the tool it is scoped to
/// (from `fixtures.errors[].tool`); without a hint the builder targets the
/// first catalog tool with a required argument, which only matters for
/// fixtures scoped `applies_to: any`.
#[derive(Debug)]
pub struct SuggestionContext<'a> {
    /// Server key written into every draft's `server:` field.
    pub server_name: &'a str,
    /// Test names already present in the analyzed suite or run; drafts
    /// never reuse one of these.
    pub existing_test_names: &'a BTreeSet<String>,
    /// Fixture name to scoped tool, for error-path drafts.
    pub error_path_tools: &'a BTreeMap<String, String>,
}

/// Build draft tests for every gap in `report` against `catalog` (the
/// `tools/list` entries, wire or snake_case schema spelling).
///
/// Unsatisfiable schemas are skipped rather than rendered as tests that
/// can never pass, matching what `generate suite` does.
///
/// ```
/// use std::collections::{BTreeMap, BTreeSet};
/// use mcptest_core::coverage::{build_suggestions, CoverageReport, SuggestionContext};
/// use serde_json::json;
///
/// let mut report = CoverageReport::empty();
/// report.tools.push(mcptest_core::coverage::ToolCoverageEntry {
///     name: "echo".into(),
///     called: false,
///     call_count: 0,
///     arg_combinations_seen: vec![],
///     error_paths_seen: vec![],
/// });
/// let catalog = vec![json!({ "name": "echo", "inputSchema": { "type": "object" } })];
/// let names = BTreeSet::new();
/// let hints = BTreeMap::new();
/// let ctx = SuggestionContext {
///     server_name: "server",
///     existing_test_names: &names,
///     error_path_tools: &hints,
/// };
/// let suggestions = build_suggestions(&report, &catalog, &ctx);
/// assert!(suggestions.combined_yaml().contains("# suggested by mcptest coverage"));
/// ```
pub fn build_suggestions(
    report: &CoverageReport,
    catalog: &[Value],
    ctx: &SuggestionContext<'_>,
) -> Suggestions {
    let schemas = catalog_schemas(catalog);
    let mut used: BTreeSet<String> = ctx.existing_test_names.clone();
    let mut items = Vec::new();
    suggest_uncovered_tools(report, &schemas, ctx, &mut used, &mut items);
    suggest_uncovered_args(report, &schemas, ctx, &mut used, &mut items);
    suggest_error_paths(report, &schemas, ctx, &mut used, &mut items);
    Suggestions { items }
}

/// Catalog as ordered `(name, input schema)` pairs. Tools without a name
/// are skipped, mirroring the generators.
fn catalog_schemas(catalog: &[Value]) -> Vec<(String, Value)> {
    catalog
        .iter()
        .filter_map(|tool| {
            let name = tool.get("name").and_then(Value::as_str)?;
            if name.is_empty() {
                return None;
            }
            Some((name.to_owned(), input_schema_of(tool)))
        })
        .collect()
}

/// Input schema in either wire (`inputSchema`) or mock-manifest
/// (`input_schema`) spelling. Local copy of the stub_gen helper, which is
/// crate-private to that module's renderers.
fn input_schema_of(tool: &Value) -> Value {
    tool.get("inputSchema")
        .or_else(|| tool.get("input_schema"))
        .cloned()
        .unwrap_or(Value::Null)
}

/// One happy-path draft per tool the suite never called.
fn suggest_uncovered_tools(
    report: &CoverageReport,
    schemas: &[(String, Value)],
    ctx: &SuggestionContext<'_>,
    used: &mut BTreeSet<String>,
    items: &mut Vec<SuggestionItem>,
) {
    for entry in report.tools.iter().filter(|t| !t.called) {
        let Some((_, schema)) = schemas.iter().find(|(n, _)| *n == entry.name) else {
            continue;
        };
        let happy = synthesize_args(schema);
        let Some(args) = happy.value.as_ref().and_then(Value::as_object) else {
            // Unsatisfiable constraints: a draft that can never pass is
            // not "ready to merge", so emit nothing for this tool.
            continue;
        };
        let name = unique_name(&format!("{}: valid arguments", entry.name), used);
        let yaml = render_draft(&DraftCase {
            name: &name,
            server: ctx.server_name,
            tool: &entry.name,
            inject_error: None,
            args,
            expect_target: "result",
            expect_message: "result should be a structured tool result",
        });
        items.push(SuggestionItem {
            kind: SuggestionKind::Tool,
            tool: entry.name.clone(),
            arg: None,
            yaml,
        });
    }
}

/// One draft per uncovered optional argument of a tool the suite did
/// call: required args plus that argument, so the call stays valid.
fn suggest_uncovered_args(
    report: &CoverageReport,
    schemas: &[(String, Value)],
    ctx: &SuggestionContext<'_>,
    used: &mut BTreeSet<String>,
    items: &mut Vec<SuggestionItem>,
) {
    for entry in report.tools.iter().filter(|t| t.called) {
        let Some((_, schema)) = schemas.iter().find(|(n, _)| *n == entry.name) else {
            continue;
        };
        let happy = synthesize_args(schema);
        let Some(base_args) = happy.value.as_ref().and_then(Value::as_object) else {
            continue;
        };
        let seen = seen_arg_names(entry);
        let required: BTreeSet<String> = required_properties(schema)
            .into_iter()
            .map(|(name, _)| name)
            .collect();
        for (prop, prop_schema) in optional_properties(schema, &required) {
            if seen.contains(&prop) {
                continue;
            }
            let Some(value) = synthesize_value(&prop_schema, schema).value else {
                continue;
            };
            let mut args = base_args.clone();
            args.insert(prop.clone(), value);
            let name = unique_name(&format!("{}: covers {prop}", entry.name), used);
            let yaml = render_draft(&DraftCase {
                name: &name,
                server: ctx.server_name,
                tool: &entry.name,
                inject_error: None,
                args: &args,
                expect_target: "result",
                expect_message: "result should be a structured tool result",
            });
            items.push(SuggestionItem {
                kind: SuggestionKind::Arg,
                tool: entry.name.clone(),
                arg: Some(prop),
                yaml,
            });
        }
    }
}

/// One missing-required-rejection draft per error path that never fired:
/// the call omits a required argument and injects the named fixture, and
/// the assertion pins the JSON-RPC error object the injection produces.
fn suggest_error_paths(
    report: &CoverageReport,
    schemas: &[(String, Value)],
    ctx: &SuggestionContext<'_>,
    used: &mut BTreeSet<String>,
    items: &mut Vec<SuggestionItem>,
) {
    for entry in report.error_paths.iter().filter(|e| !e.triggered) {
        let Some((tool, schema)) = error_path_target(&entry.name, schemas, ctx) else {
            continue;
        };
        let args = args_missing_first_required(schema);
        let name = unique_name(&format!("{tool}: covers error path {}", entry.name), used);
        let message = format!("error path {} should surface a JSON-RPC error", entry.name);
        let yaml = render_draft(&DraftCase {
            name: &name,
            server: ctx.server_name,
            tool,
            inject_error: Some(&entry.name),
            args: &args,
            expect_target: "result.error",
            expect_message: &message,
        });
        items.push(SuggestionItem {
            kind: SuggestionKind::ErrorPath,
            tool: tool.to_owned(),
            arg: None,
            yaml,
        });
    }
}

/// Pick the tool an error-path draft calls: the fixture's scoped tool
/// when hinted, otherwise the first catalog tool with a required
/// argument (so "missing required" is constructible), otherwise the
/// first catalog tool. `None` only for an empty catalog.
fn error_path_target<'a>(
    error_path: &str,
    schemas: &'a [(String, Value)],
    ctx: &SuggestionContext<'_>,
) -> Option<(&'a str, &'a Value)> {
    if let Some(hinted) = ctx.error_path_tools.get(error_path) {
        if let Some((name, schema)) = schemas.iter().find(|(n, _)| n == hinted) {
            return Some((name, schema));
        }
    }
    schemas
        .iter()
        .find(|(_, schema)| !required_properties(schema).is_empty())
        .or_else(|| schemas.first())
        .map(|(name, schema)| (name.as_str(), schema))
}

/// Happy-path args with the first required property removed, so the
/// draft reads as a genuine missing-required failure case. A tool with
/// no required args (or an unsatisfiable schema) yields empty args; the
/// injected fixture still drives the error.
fn args_missing_first_required(schema: &Value) -> Map<String, Value> {
    let mut args = synthesize_args(schema)
        .value
        .as_ref()
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    if let Some((first_required, _)) = required_properties(schema).into_iter().next() {
        args.remove(&first_required);
    }
    args
}

/// Argument names this tool was observed with, from the pairwise
/// combinations the tracker recorded.
fn seen_arg_names(entry: &super::report::ToolCoverageEntry) -> BTreeSet<String> {
    entry
        .arg_combinations_seen
        .iter()
        .flat_map(|combo| combo.pairs.iter().map(|(name, _)| name.clone()))
        .collect()
}

/// Schema properties that are not required, in declaration order.
fn optional_properties(schema: &Value, required: &BTreeSet<String>) -> Vec<(String, Value)> {
    schema
        .get("properties")
        .and_then(Value::as_object)
        .map(|props| {
            props
                .iter()
                .filter(|(name, _)| !required.contains(*name))
                .map(|(name, prop)| (name.clone(), prop.clone()))
                .collect()
        })
        .unwrap_or_default()
}

/// Derive a name that collides with nothing in `used`, then reserve it.
/// The base name wins when free; collisions get a " (suggested)" suffix,
/// then a counter, so re-running suggest against a merged suite still
/// produces fresh names. Crate-visible so `diff`'s regen builder shares
/// the exact dedup convention instead of inventing a second one.
pub(crate) fn unique_name(base: &str, used: &mut BTreeSet<String>) -> String {
    let mut candidate = base.to_owned();
    if used.contains(&candidate) {
        candidate = format!("{base} (suggested)");
    }
    let mut counter = 2usize;
    while used.contains(&candidate) {
        candidate = format!("{base} (suggested {counter})");
        counter += 1;
    }
    used.insert(candidate.clone());
    candidate
}

/// The fields of one rendered draft. Bundled so [`render_draft`] takes
/// one parameter instead of seven. Crate-visible so `diff`'s regen
/// builder renders drafts in exactly this shape.
pub(crate) struct DraftCase<'a> {
    pub(crate) name: &'a str,
    pub(crate) server: &'a str,
    pub(crate) tool: &'a str,
    pub(crate) inject_error: Option<&'a str>,
    pub(crate) args: &'a Map<String, Value>,
    pub(crate) expect_target: &'a str,
    pub(crate) expect_message: &'a str,
}

/// Render one draft as a `tools:` list entry, marker comment first. The
/// shape matches what `generate suite` emits (name, server, tool, args,
/// expect with a `schema:` matcher) so merged suites stay uniform.
fn render_draft(case: &DraftCase<'_>) -> String {
    render_draft_with_marker(case, SUGGESTION_MARKER)
}

/// [`render_draft`] with the marker line as a parameter, so other draft
/// producers (the diff regen builder) emit the same YAML shape under
/// their own provenance comment.
pub(crate) fn render_draft_with_marker(case: &DraftCase<'_>, marker: &str) -> String {
    let mut s = String::new();
    s.push_str(&format!("  {marker}\n"));
    s.push_str(&format!("  - name: {}\n", yaml_scalar(&json!(case.name))));
    s.push_str(&format!("    server: {}\n", case.server));
    s.push_str(&format!("    tool: {}\n", case.tool));
    if let Some(fixture) = case.inject_error {
        s.push_str(&format!("    inject_error: {fixture}\n"));
    }
    s.push_str(&render_args_block(case.args));
    s.push_str("    expect:\n");
    s.push_str(&format!("      - target: {}\n", case.expect_target));
    s.push_str("        matcher:\n");
    s.push_str("          schema:\n");
    s.push_str("            type: object\n");
    s.push_str(&format!(
        "        message: {}\n",
        yaml_scalar(&json!(case.expect_message))
    ));
    s
}

/// Render the `args:` block at test-entry indentation. An empty map uses
/// the inline `{}` form, matching the generators.
fn render_args_block(args: &Map<String, Value>) -> String {
    if args.is_empty() {
        return "    args: {}\n".to_string();
    }
    let mut s = String::from("    args:\n");
    for (key, value) in args {
        s.push_str(&format!("      {key}: {}\n", yaml_scalar(value)));
    }
    s
}

/// One-line YAML scalar for a JSON value. YAML is a JSON superset, so
/// compact JSON renders arrays and objects cleanly; strings are
/// double-quoted with the two characters that need escaping escaped.
fn yaml_scalar(value: &Value) -> String {
    match value {
        Value::Null => "null".to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
        Value::Array(_) | Value::Object(_) => value.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::coverage::report::{ArgCombination, ErrorPathCoverageEntry, ToolCoverageEntry};

    fn tool_entry(name: &str, called: bool, combos: Vec<ArgCombination>) -> ToolCoverageEntry {
        ToolCoverageEntry {
            name: name.into(),
            called,
            call_count: usize::from(called),
            arg_combinations_seen: combos,
            error_paths_seen: Vec::new(),
        }
    }

    fn report_with(
        tools: Vec<ToolCoverageEntry>,
        error_paths: Vec<(&str, bool)>,
    ) -> CoverageReport {
        let mut report = CoverageReport::empty();
        report.tools = tools;
        report.error_paths = error_paths
            .into_iter()
            .map(|(name, triggered)| ErrorPathCoverageEntry {
                name: name.into(),
                triggered,
            })
            .collect();
        report
    }

    fn search_catalog() -> Vec<Value> {
        vec![json!({
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "q": { "type": "string" },
                    "limit": { "type": "integer", "minimum": 1 }
                },
                "required": ["q"]
            }
        })]
    }

    fn ctx<'a>(
        names: &'a BTreeSet<String>,
        hints: &'a BTreeMap<String, String>,
    ) -> SuggestionContext<'a> {
        SuggestionContext {
            server_name: "server",
            existing_test_names: names,
            error_path_tools: hints,
        }
    }

    use serde_json::json;

    #[test]
    fn uncovered_tool_gets_a_happy_path_draft_with_the_marker_comment() {
        let report = report_with(vec![tool_entry("search", false, vec![])], vec![]);
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        assert_eq!(suggestions.items.len(), 1);
        let item = &suggestions.items[0];
        assert_eq!(item.kind, SuggestionKind::Tool);
        assert_eq!(item.tool, "search");
        assert!(item.yaml.starts_with(&format!("  {SUGGESTION_MARKER}\n")));
        assert!(item.yaml.contains("- name: \"search: valid arguments\""));
        // Only the required argument is synthesized for the happy path.
        assert!(item.yaml.contains("q: \"sample\""));
        assert!(!item.yaml.contains("limit:"));
    }

    #[test]
    fn covered_tool_with_all_args_seen_yields_no_items() {
        let combos = vec![ArgCombination::from_pairs(vec![
            ("q".into(), "x".into()),
            ("limit".into(), "1".into()),
        ])];
        let report = report_with(vec![tool_entry("search", true, combos)], vec![]);
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        assert!(suggestions.is_empty());
        assert_eq!(suggestions.combined_yaml(), "");
    }

    #[test]
    fn uncovered_optional_arg_gets_a_draft_setting_it_alongside_required_args() {
        let combos = vec![ArgCombination::from_pairs(vec![("q".into(), "x".into())])];
        let report = report_with(vec![tool_entry("search", true, combos)], vec![]);
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        assert_eq!(suggestions.items.len(), 1);
        let item = &suggestions.items[0];
        assert_eq!(item.kind, SuggestionKind::Arg);
        assert_eq!(item.arg.as_deref(), Some("limit"));
        assert!(item.yaml.contains("- name: \"search: covers limit\""));
        assert!(item.yaml.contains("q: \"sample\""));
        // Bound-aware synthesis: the schema's minimum is honored.
        assert!(item.yaml.contains("limit: 1"));
    }

    #[test]
    fn uncovered_error_path_gets_a_missing_required_rejection_draft() {
        let report = report_with(
            vec![tool_entry("search", true, vec![])],
            vec![("rate_limited", false)],
        );
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        let item = suggestions
            .items
            .iter()
            .find(|i| i.kind == SuggestionKind::ErrorPath)
            .expect("an error-path draft");
        assert_eq!(item.tool, "search");
        assert!(item.yaml.contains("inject_error: rate_limited"));
        // The required `q` is deliberately omitted.
        assert!(item.yaml.contains("args: {}"));
        assert!(item.yaml.contains("target: result.error"));
    }

    #[test]
    fn triggered_error_path_yields_no_draft() {
        let report = report_with(
            vec![tool_entry("search", true, vec![])],
            vec![("rate_limited", true)],
        );
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        assert!(suggestions
            .items
            .iter()
            .all(|i| i.kind != SuggestionKind::ErrorPath));
    }

    #[test]
    fn error_path_hint_routes_the_draft_to_the_scoped_tool() {
        let mut catalog = search_catalog();
        catalog.push(json!({
            "name": "create_issue",
            "inputSchema": {
                "type": "object",
                "properties": { "title": { "type": "string" } },
                "required": ["title"]
            }
        }));
        let report = report_with(
            vec![
                tool_entry("search", true, vec![]),
                tool_entry("create_issue", true, vec![]),
            ],
            vec![("invalid_title", false)],
        );
        let names = BTreeSet::new();
        let mut hints = BTreeMap::new();
        hints.insert("invalid_title".to_string(), "create_issue".to_string());
        let suggestions = build_suggestions(&report, &catalog, &ctx(&names, &hints));
        let item = suggestions
            .items
            .iter()
            .find(|i| i.kind == SuggestionKind::ErrorPath)
            .expect("an error-path draft");
        assert_eq!(item.tool, "create_issue");
    }

    #[test]
    fn draft_names_never_collide_with_existing_test_names() {
        let report = report_with(vec![tool_entry("search", false, vec![])], vec![]);
        let mut names = BTreeSet::new();
        names.insert("search: valid arguments".to_string());
        names.insert("search: valid arguments (suggested)".to_string());
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        assert!(suggestions.items[0]
            .yaml
            .contains("- name: \"search: valid arguments (suggested 2)\""));
    }

    #[test]
    fn unsatisfiable_schema_skips_the_tool_draft() {
        let catalog = vec![json!({
            "name": "broken",
            "inputSchema": {
                "type": "object",
                "properties": { "n": { "type": "integer", "minimum": 10, "maximum": 3 } },
                "required": ["n"]
            }
        })];
        let report = report_with(vec![tool_entry("broken", false, vec![])], vec![]);
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &catalog, &ctx(&names, &hints));
        assert!(suggestions.is_empty());
    }

    #[test]
    fn combined_yaml_parses_and_nests_drafts_under_the_tools_key() {
        let combos = vec![ArgCombination::from_pairs(vec![("q".into(), "x".into())])];
        let report = report_with(
            vec![
                tool_entry("search", true, combos),
                tool_entry("missing_tool", false, vec![]),
            ],
            vec![("rate_limited", false)],
        );
        let mut catalog = search_catalog();
        catalog.push(json!({ "name": "missing_tool", "inputSchema": { "type": "object" } }));
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &catalog, &ctx(&names, &hints));
        let combined = suggestions.combined_yaml();
        let parsed: serde_yaml::Value = serde_yaml::from_str(&combined).expect("combined parses");
        let tests = parsed
            .get("tools")
            .and_then(serde_yaml::Value::as_sequence)
            .expect("a tools sequence");
        assert_eq!(tests.len(), suggestions.items.len());
        for test in tests {
            assert!(test.get("name").is_some());
            assert!(test.get("server").is_some());
            assert!(test.get("tool").is_some());
            assert!(test.get("expect").is_some());
        }
    }

    #[test]
    fn to_json_carries_the_combined_yaml_and_structured_items() {
        let report = report_with(vec![tool_entry("search", false, vec![])], vec![]);
        let names = BTreeSet::new();
        let hints = BTreeMap::new();
        let suggestions = build_suggestions(&report, &search_catalog(), &ctx(&names, &hints));
        let wire = suggestions.to_json();
        assert_eq!(wire["items"][0]["kind"], "tool");
        assert_eq!(wire["items"][0]["tool"], "search");
        assert!(wire["yaml"]
            .as_str()
            .unwrap()
            .starts_with("tools:\n  # suggested by mcptest coverage"));
    }
}
