diff --git a/CHANGELOG.md b/CHANGELOG.md index e34be3e..d30b7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Before `1.0.0`, breaking changes may still ship in minor releases. ## [Unreleased] +### Added + +- `kagi mcp` now exposes a `kagi_news` tool that fetches Kagi News stories without authentication (accepts `category`, `limit`, and `lang`) +- `kagi search --news` searches the News tab of kagi.com and returns results grouped into story clusters (session auth required); supports `--region`, `--time` (day/week/month), `--order` (default/recency/website), and `--limit` +- `kagi mcp` exposes a `kagi_news_search` tool wrapping the News-tab vertical (accepts `query`, `region`, `freshness`, `order`, `limit`) + +### Fixed + +- `kagi news` no longer fails to parse live responses; `total_stories` is now an integer in the output (previously typed as a string, which never matched the API's actual integer payload) + ## [0.5.1] ### Added diff --git a/docs/SKILL.md b/docs/SKILL.md index 84be416..8dc33da 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -69,7 +69,7 @@ export KAGI_API_TOKEN='...' | Credential | What It Unlocks | |------------|-----------------| -| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` | +| `KAGI_SESSION_TOKEN` | base search fallback, `search --lens`, `search --news`, filtered search, `quick`, `ask-page`, `assistant`, `translate`, `summarize --subscriber` | | `KAGI_API_TOKEN` | summarize, fastgpt, enrich web, enrich news | | none | news, smallweb, auth status, --help | @@ -91,6 +91,9 @@ kagi search --format pretty "query" # Search with lens kagi search --lens 2 "query" +# News-tab search (returns clusters of articles, session-only) +kagi search --news "iran" + # Filtered search kagi search --time month --region us --order recency "rust release notes" @@ -436,7 +439,7 @@ kagi batch "topic1" "topic2" "topic3" --format csv > comparison.csv ## Constraints -- Session token required for: search --lens, quick, ask-page, assistant, translate, summarize --subscriber +- Session token required for: search --lens, search --news, quick, ask-page, assistant, translate, summarize --subscriber - API token required for: summarize (public API), fastgpt, enrich - Rate limits apply based on Kagi subscription tier - API usage has per-query costs; session-based features included with subscription diff --git a/docs/commands/mcp.mdx b/docs/commands/mcp.mdx index d59ea21..fe193ad 100644 --- a/docs/commands/mcp.mdx +++ b/docs/commands/mcp.mdx @@ -18,6 +18,8 @@ kagi mcp - `kagi_search` - search Kagi - `kagi_summarize` - summarize a URL or text through the public API - `kagi_quick` - get a Kagi Quick Answer +- `kagi_news` - fetch Kagi News stories (no auth required). Accepts `category` (default `world`), `limit` (default `12`), and `lang` (default `default`). +- `kagi_news_search` - search the News tab of kagi.com and return story clusters (session token required). Accepts `query`, optional `region`, `freshness` (`day`/`week`/`month`), `order` (`default`/`recency`/`website`), and `limit`. The server reads one JSON-RPC request per stdin line and writes one JSON-RPC response per stdout line. This keeps the implementation dependency-light and easy to supervise from agent runners. diff --git a/docs/commands/news.mdx b/docs/commands/news.mdx index 4b6cce3..4fdf421 100644 --- a/docs/commands/news.mdx +++ b/docs/commands/news.mdx @@ -173,7 +173,7 @@ kagi news --filter-keyword trump --filter-scope title "category_name": "Technology" }, "stories": [], - "total_stories": "20", + "total_stories": 20, "domains": [], "read_count": 1234, "content_filter": { @@ -194,7 +194,7 @@ kagi news --filter-keyword trump --filter-scope title | `latest_batch` | object | Metadata for the current news batch | | `category` | object | Resolved category metadata | | `stories` | array | Raw story payloads returned by Kagi News | -| `total_stories` | string | Total number of stories in the category | +| `total_stories` | integer | Total number of stories in the category | | `domains` | array | Domain-level metadata from the feed | | `read_count` | integer | Aggregate read count for the category | | `content_filter` | object | Present when local content filters are active | diff --git a/docs/commands/search.mdx b/docs/commands/search.mdx index 1617d95..62b6bfe 100644 --- a/docs/commands/search.mdx +++ b/docs/commands/search.mdx @@ -208,6 +208,21 @@ Cap the number of results returned. Truncation happens locally after Kagi's resp kagi search "rust async runtime tradeoffs" --limit 5 ``` +### `--news` + +Search the News tab of `kagi.com` (`kagi.com/news?q=...`) instead of web search. Returns articles grouped into story clusters (multiple sources covering the same story share a cluster). Forces session-token auth. + +```bash +kagi search "iran" --news +kagi search "iran" --news --time day --order recency --format pretty +``` + +Supported flags with `--news`: `--region`, `--time` (day, week, month only — not `year`), `--order` (default, recency, website only — not `trackers`), `--limit`, `--format`, `--local-cache`, `--cache-ttl`. + +Rejected flags with `--news`: `--lens`, `--snap`, `--from-date`, `--to-date`, `--verbatim`, `--personalized`, `--no-personalized`, `--follow`, `--template`. Each produces a clear error. + +The JSON shape is `NewsSearchResponse`, distinct from web search. See [Output Format](#output-format) below. + ## Not Supported as Runtime Flags `safe_search` is currently an account setting, not a search-time flag in this CLI. Configure it in the Kagi web settings instead of expecting `kagi search` to override it per request. @@ -231,6 +246,31 @@ The search response shape is unchanged: } ``` +With `--news`, the response shape is different — clusters of news articles: + +```json +{ + "query": "iran", + "clusters": [ + { + "items": [ + { + "title": "Lead headline", + "url": "https://www.example.com/article", + "source": "example.com", + "time_relative": "2 hours ago", + "snippet": "Optional snippet text.", + "paywall": false, + "image_url": "https://img.example/lead.jpg" + } + ] + } + ] +} +``` + +`time_relative` is a verbatim string like `"2 hours ago"` or `"Yesterday"`; the News page does not expose absolute timestamps. Cluster ordering reflects Kagi's page order. Single-item clusters represent ungrouped articles at the top of the page. + ## Examples ### Plain base search @@ -287,6 +327,12 @@ kagi search --verbatim --no-personalized "\"rust book ownership\"" kagi search --lens 2 --region us --time year "developer documentation" ``` +### News-tab search + +```bash +kagi search "iran" --news --time day --format pretty +``` + ## Processing Results ```bash diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index 156321c..a0cfd98 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -33,6 +33,33 @@ This page documents the current CLI output behavior as implemented in the repo. } ``` +### `kagi search --news` + +The News tab vertical returns a different shape — clusters of articles: + +```json +{ + "query": "iran", + "clusters": [ + { + "items": [ + { + "title": "Headline", + "url": "https://www.example.com/article", + "source": "example.com", + "time_relative": "2 hours ago", + "snippet": "Optional snippet text.", + "paywall": false, + "image_url": "https://img.example/lead.jpg" + } + ] + } + ] +} +``` + +`time_relative` is preserved verbatim from Kagi's page (the News tab does not expose absolute timestamps). Single-item clusters represent ungrouped articles at the top of the results page. + ### `kagi batch` Default JSON output uses a structured envelope: @@ -105,7 +132,7 @@ Subscriber mode: "category_name": "Technology" }, "stories": [], - "total_stories": "20", + "total_stories": 20, "domains": [], "read_count": 1234 } diff --git a/src/cli.rs b/src/cli.rs index a511ad1..9181d38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -340,6 +340,13 @@ pub struct SearchArgs { /// Maximum number of search results to return #[arg(long, value_name = "N")] pub limit: Option, + + /// Search the News tab of kagi.com (kagi.com/news?q=...) instead of web results. + /// Forces session auth. Conflicts with --lens, --snap, --from-date, --to-date, + /// --verbatim, --personalized, --no-personalized, --follow, --template, and + /// rejects --time year and --order trackers (no equivalents in the news vertical). + #[arg(long)] + pub news: bool, } impl SearchArgs { @@ -353,6 +360,51 @@ impl SearchArgs { } Ok(()) } + + /// Validates the flag combinations allowed with `--news`. + /// + /// # Errors + /// Returns a config error message when an incompatible flag is combined with `--news`. + pub fn validate_news_search(&self) -> Result<(), String> { + if !self.news { + return Ok(()); + } + if self.lens.is_some() { + return Err("--news cannot be combined with --lens".to_string()); + } + if self.snap.is_some() { + return Err("--news cannot be combined with --snap".to_string()); + } + if self.from_date.is_some() || self.to_date.is_some() { + return Err("--news cannot be combined with --from-date or --to-date".to_string()); + } + if self.verbatim { + return Err("--news cannot be combined with --verbatim".to_string()); + } + if self.personalized || self.no_personalized { + return Err( + "--news cannot be combined with --personalized or --no-personalized".to_string(), + ); + } + if self.follow.is_some() { + return Err("--news cannot be combined with --follow".to_string()); + } + if self.template.is_some() { + return Err("--news cannot be combined with --template".to_string()); + } + if matches!(self.time, Some(SearchTime::Year)) { + return Err( + "--time year is not supported with --news (only day, week, or month)".to_string(), + ); + } + if matches!(self.order, Some(SearchOrder::Trackers)) { + return Err( + "--order trackers is not supported with --news (use default, recency, or website)" + .to_string(), + ); + } + Ok(()) + } } #[derive(Debug, Args)] @@ -1548,9 +1600,36 @@ pub struct RedirectUpdateArgs { #[cfg(test)] mod tests { - use super::{Cli, Commands, NewsArgs, NewsFilterMode, NewsFilterScope, SummarizeArgs}; + use super::{ + Cli, Commands, NewsArgs, NewsFilterMode, NewsFilterScope, OutputFormat, SearchArgs, + SearchOrder, SearchTime, SummarizeArgs, + }; use clap::Parser; + fn sample_search_args(query: &str) -> SearchArgs { + SearchArgs { + query: query.to_string(), + format: OutputFormat::Json, + no_color: false, + snap: None, + lens: None, + region: None, + time: None, + from_date: None, + to_date: None, + order: None, + verbatim: false, + personalized: false, + no_personalized: false, + template: None, + local_cache: false, + cache_ttl: None, + follow: None, + limit: None, + news: false, + } + } + fn sample_news_args() -> NewsArgs { NewsArgs { category: "world".to_string(), @@ -1598,6 +1677,63 @@ mod tests { assert!(args.has_filter_inputs()); } + #[test] + fn news_search_passthrough_flags_are_accepted() { + let mut args = sample_search_args("iran"); + args.news = true; + args.region = Some("us".to_string()); + args.time = Some(SearchTime::Day); + args.order = Some(SearchOrder::Recency); + args.limit = Some(5); + assert!(args.validate_news_search().is_ok()); + } + + #[test] + fn news_search_rejects_lens() { + let mut args = sample_search_args("iran"); + args.news = true; + args.lens = Some("1".to_string()); + let err = args + .validate_news_search() + .expect_err("--news + --lens should fail"); + assert!(err.contains("--lens")); + } + + #[test] + fn news_search_rejects_time_year() { + let mut args = sample_search_args("iran"); + args.news = true; + args.time = Some(SearchTime::Year); + let err = args + .validate_news_search() + .expect_err("--news + --time year should fail"); + assert!(err.contains("--time year")); + } + + #[test] + fn news_search_rejects_order_trackers() { + let mut args = sample_search_args("iran"); + args.news = true; + args.order = Some(SearchOrder::Trackers); + let err = args + .validate_news_search() + .expect_err("--news + --order trackers should fail"); + assert!(err.contains("--order trackers")); + } + + #[test] + fn news_search_rejects_template_and_follow() { + let mut args = sample_search_args("iran"); + args.news = true; + args.template = Some("{{title}}".to_string()); + assert!(args.validate_news_search().is_err()); + + let mut args = sample_search_args("iran"); + args.news = true; + args.follow = Some(2); + assert!(args.validate_news_search().is_err()); + } + #[test] fn rejects_summarize_args_without_url_or_text() { let args = SummarizeArgs { diff --git a/src/main.rs b/src/main.rs index 5b1ad1e..2b6a310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,17 +39,17 @@ use crate::cli::{ AssistantCustomSubcommand, AssistantOutputFormat, AssistantReplArgs, AssistantSubcommand, AssistantThreadExportFormat, AssistantThreadSubcommand, AuthSetArgs, AuthSubcommand, BangSubcommand, Cli, Commands, CompletionShell, CustomBangSubcommand, EnrichSubcommand, - HistorySubcommand, McpArgs, NotifyArgs, SearchOrder, SearchTime, SitePrefMode, - SitePrefSubcommand, TranslateArgs, WatchArgs, + HistorySubcommand, McpArgs, NotifyArgs, OutputFormat, SearchArgs, SearchOrder, SearchTime, + SitePrefMode, SitePrefSubcommand, TranslateArgs, WatchArgs, }; use crate::error::KagiError; use crate::quick::{execute_quick, format_quick_markdown, format_quick_pretty}; use crate::types::{ AskPageRequest, AssistantProfileCreateRequest, AssistantProfileUpdateRequest, AssistantPromptRequest, CustomBangCreateRequest, CustomBangUpdateRequest, FastGptRequest, - LensCreateRequest, LensUpdateRequest, QuickResponse, RedirectRuleCreateRequest, - RedirectRuleUpdateRequest, SearchResponse, SubscriberSummarizeRequest, SummarizeRequest, - TranslateCommandRequest, + LensCreateRequest, LensUpdateRequest, NewsSearchResponse, QuickResponse, + RedirectRuleCreateRequest, RedirectRuleUpdateRequest, SearchResponse, + SubscriberSummarizeRequest, SummarizeRequest, TranslateCommandRequest, }; use serde::Serialize; use serde::de::DeserializeOwned; @@ -132,6 +132,21 @@ async fn run() -> Result<(), KagiError> { Commands::Search(args) => { args.validate().map_err(KagiError::Config)?; + if args.news { + args.validate_news_search().map_err(KagiError::Config)?; + let token = resolve_session_token(profile.as_deref())?; + let request = build_news_search_request(&args); + let response = cached_json( + args.local_cache, + args.cache_ttl.unwrap_or(900), + "news-search", + &request, + || async { search::execute_news_search(&request, &token).await }, + ) + .await?; + return print_news_search(&response, &args.format, !args.no_color); + } + let options = SearchRequestOptions { snap: args.snap, lens: args.lens, @@ -1351,6 +1366,150 @@ fn format_csv_response(response: &SearchResponse) -> String { output } +fn build_news_search_request(args: &SearchArgs) -> search::NewsSearchRequest { + let freshness = args.time.as_ref().and_then(|time| match time { + SearchTime::Day => Some(search::NewsFreshness::Day), + SearchTime::Week => Some(search::NewsFreshness::Week), + SearchTime::Month => Some(search::NewsFreshness::Month), + SearchTime::Year => None, + }); + let order = args.order.as_ref().and_then(|order| match order { + SearchOrder::Default => Some(search::NewsSearchOrder::Default), + SearchOrder::Recency => Some(search::NewsSearchOrder::Recency), + SearchOrder::Website => Some(search::NewsSearchOrder::Website), + SearchOrder::Trackers => None, + }); + search::NewsSearchRequest { + query: args.query.trim().to_string(), + region: args.region.clone(), + freshness, + order, + dir_desc: false, + limit: args.limit, + } +} + +fn print_news_search( + response: &NewsSearchResponse, + format: &OutputFormat, + use_color: bool, +) -> Result<(), KagiError> { + match format { + OutputFormat::Json => print_json(response), + OutputFormat::Compact => print_compact_json(response), + OutputFormat::Pretty => { + println!("{}", format_pretty_news_response(response, use_color)); + Ok(()) + } + OutputFormat::Markdown => { + println!("{}", format_markdown_news_response(response)); + Ok(()) + } + OutputFormat::Csv => { + println!("{}", format_csv_news_response(response)); + Ok(()) + } + } +} + +fn format_pretty_news_response(response: &NewsSearchResponse, use_color: bool) -> String { + if response.clusters.is_empty() { + return "No news results found.".to_string(); + } + let bold = if use_color { "\x1b[1;34m" } else { "" }; + let dim = if use_color { "\x1b[2m" } else { "" }; + let url_color = if use_color { "\x1b[36m" } else { "" }; + let reset = if use_color { "\x1b[0m" } else { "" }; + + let mut blocks = Vec::with_capacity(response.clusters.len()); + for (cluster_index, cluster) in response.clusters.iter().enumerate() { + let mut lines = Vec::with_capacity(cluster.items.len() + 1); + lines.push(format!( + "{dim}── Cluster {}{reset}", + cluster_index + 1, + dim = dim, + reset = reset, + )); + for item in &cluster.items { + let header = match (item.source.as_deref(), item.time_relative.as_deref()) { + (Some(source), Some(time)) => format!("{source} · {time}"), + (Some(source), None) => source.to_string(), + (None, Some(time)) => time.to_string(), + (None, None) => String::new(), + }; + let paywall = if item.paywall { " [paywall]" } else { "" }; + if header.is_empty() { + lines.push(format!( + "{bold}{}{reset}{paywall}\n {url_color}{}{reset}", + item.title, item.url + )); + } else { + lines.push(format!( + "{dim}{header}{reset}{paywall}\n {bold}{}{reset}\n {url_color}{}{reset}", + item.title, item.url + )); + } + if let Some(snippet) = item.snippet.as_deref() { + lines.push(format!(" {snippet}")); + } + } + blocks.push(lines.join("\n")); + } + blocks.join("\n\n") +} + +fn format_markdown_news_response(response: &NewsSearchResponse) -> String { + if response.clusters.is_empty() { + return "# No news results found.".to_string(); + } + let mut sections = Vec::with_capacity(response.clusters.len()); + for (cluster_index, cluster) in response.clusters.iter().enumerate() { + let mut section = format!("## Cluster {}\n\n", cluster_index + 1); + for item in &cluster.items { + let suffix = match (item.source.as_deref(), item.time_relative.as_deref()) { + (Some(source), Some(time)) => format!(" — {source}, {time}"), + (Some(source), None) => format!(" — {source}"), + (None, Some(time)) => format!(" — {time}"), + (None, None) => String::new(), + }; + let paywall = if item.paywall { " *(paywall)*" } else { "" }; + section.push_str(&format!( + "- [{}]({}){suffix}{paywall}\n", + item.title, item.url, + )); + if let Some(snippet) = item.snippet.as_deref() { + section.push_str(&format!(" {snippet}\n")); + } + } + sections.push(section); + } + sections.join("\n") +} + +fn format_csv_news_response(response: &NewsSearchResponse) -> String { + let header = "cluster,source,time_relative,title,url,paywall,snippet"; + if response.clusters.is_empty() { + return header.to_string(); + } + let mut output = String::from(header); + output.push('\n'); + for (cluster_index, cluster) in response.clusters.iter().enumerate() { + for item in &cluster.items { + let cluster_index = (cluster_index + 1).to_string(); + let source = escape_csv_field(item.source.as_deref().unwrap_or("")); + let time = escape_csv_field(item.time_relative.as_deref().unwrap_or("")); + let title = escape_csv_field(&item.title); + let url = escape_csv_field(&item.url); + let paywall = if item.paywall { "true" } else { "false" }; + let snippet = escape_csv_field(item.snippet.as_deref().unwrap_or("")); + output.push_str(&format!( + "{cluster_index},{source},{time},{title},{url},{paywall},{snippet}\n" + )); + } + } + output +} + /// Simple rate limiter using token bucket algorithm struct RateLimiter { capacity: u32, @@ -1870,32 +2029,46 @@ async fn run_mcp(args: McpArgs, profile: Option<&str>) -> Result<(), KagiError> continue; } let request: Value = serde_json::from_str(&line)?; - let id = request.get("id").cloned().unwrap_or(Value::Null); + // Notifications have no `id` field; per JSON-RPC 2.0 they must not be answered. + let Some(id) = request.get("id").cloned() else { + continue; + }; let method = request.get("method").and_then(Value::as_str).unwrap_or(""); - let result = match method { + let response = match method { "initialize" => serde_json::json!({ - "protocolVersion": "2024-11-05", - "serverInfo": {"name": "kagi-cli", "version": env!("CARGO_PKG_VERSION")}, - "capabilities": {"tools": {}} + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2024-11-05", + "serverInfo": {"name": "kagi-cli", "version": env!("CARGO_PKG_VERSION")}, + "capabilities": {"tools": {}} + } }), "tools/list" => serde_json::json!({ - "tools": [ - {"name": "kagi_search", "description": "Search Kagi", "inputSchema": {"type": "object"}}, - {"name": "kagi_summarize", "description": "Summarize a URL or text", "inputSchema": {"type": "object"}}, - {"name": "kagi_quick", "description": "Get a Kagi Quick Answer", "inputSchema": {"type": "object"}} - ] + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": [ + {"name": "kagi_search", "description": "Search Kagi", "inputSchema": {"type": "object"}}, + {"name": "kagi_summarize", "description": "Summarize a URL or text", "inputSchema": {"type": "object"}}, + {"name": "kagi_quick", "description": "Get a Kagi Quick Answer", "inputSchema": {"type": "object"}}, + {"name": "kagi_news", "description": "Fetch Kagi News stories for a category", "inputSchema": {"type": "object"}}, + {"name": "kagi_news_search", "description": "Search the News tab of kagi.com (clusters of articles)", "inputSchema": {"type": "object"}} + ] + } }), - "tools/call" => run_mcp_tool_call(&request, profile).await?, - _ => serde_json::json!({"error": format!("unsupported method `{method}`")}), - }; - println!( - "{}", - serde_json::to_string(&serde_json::json!({ + "tools/call" => serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": result, - }))? - ); + "result": run_mcp_tool_call(&request, profile).await?, + }), + _ => serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": format!("Method not found: {method}")}, + }), + }; + println!("{}", serde_json::to_string(&response)?); } Ok(()) } @@ -1910,40 +2083,99 @@ async fn run_mcp_tool_call(request: &Value, profile: Option<&str>) -> Result { - let query = arguments.get("query").and_then(Value::as_str).unwrap_or(""); - let inventory = load_credential_inventory_for_profile(profile)?; - let request = search::SearchRequest::new(query.to_string()); - let credentials = inventory.resolve_for_search(SearchAuthRequirement::Base)?; - serde_json::to_string_pretty(&execute_search_request(&request, credentials).await?)? - } - "kagi_summarize" => { - let token = resolve_api_token(profile)?; - let request = SummarizeRequest { - url: arguments - .get("url") + let text = + match name { + "kagi_search" => { + let query = arguments.get("query").and_then(Value::as_str).unwrap_or(""); + let inventory = load_credential_inventory_for_profile(profile)?; + let request = search::SearchRequest::new(query.to_string()); + let credentials = inventory.resolve_for_search(SearchAuthRequirement::Base)?; + serde_json::to_string_pretty(&execute_search_request(&request, credentials).await?)? + } + "kagi_summarize" => { + let token = resolve_api_token(profile)?; + let request = SummarizeRequest { + url: arguments + .get("url") + .and_then(Value::as_str) + .map(str::to_string), + text: arguments + .get("text") + .and_then(Value::as_str) + .map(str::to_string), + engine: None, + summary_type: None, + target_language: None, + cache: None, + }; + serde_json::to_string_pretty(&execute_summarize(&request, &token).await?)? + } + "kagi_quick" => { + let token = resolve_session_token(profile)?; + let query = arguments.get("query").and_then(Value::as_str).unwrap_or(""); + let request = search::SearchRequest::new(query.to_string()); + serde_json::to_string_pretty(&execute_quick(&request, &token).await?)? + } + "kagi_news" => { + let category = arguments + .get("category") .and_then(Value::as_str) - .map(str::to_string), - text: arguments - .get("text") + .unwrap_or("world"); + let lang = arguments + .get("lang") .and_then(Value::as_str) - .map(str::to_string), - engine: None, - summary_type: None, - target_language: None, - cache: None, - }; - serde_json::to_string_pretty(&execute_summarize(&request, &token).await?)? - } - "kagi_quick" => { - let token = resolve_session_token(profile)?; - let query = arguments.get("query").and_then(Value::as_str).unwrap_or(""); - let request = search::SearchRequest::new(query.to_string()); - serde_json::to_string_pretty(&execute_quick(&request, &token).await?)? - } - _ => format!("unsupported tool `{name}`"), - }; + .unwrap_or("default"); + let limit = arguments + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as u32) + .unwrap_or(12); + serde_json::to_string_pretty(&execute_news(category, limit, lang, None).await?)? + } + "kagi_news_search" => { + let token = resolve_session_token(profile)?; + let query = arguments + .get("query") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let region = arguments + .get("region") + .and_then(Value::as_str) + .map(str::to_string); + let freshness = arguments.get("freshness").and_then(Value::as_str).and_then( + |value| match value { + "day" => Some(search::NewsFreshness::Day), + "week" => Some(search::NewsFreshness::Week), + "month" => Some(search::NewsFreshness::Month), + _ => None, + }, + ); + let order = arguments + .get("order") + .and_then(Value::as_str) + .and_then(|value| match value { + "default" => Some(search::NewsSearchOrder::Default), + "recency" => Some(search::NewsSearchOrder::Recency), + "website" => Some(search::NewsSearchOrder::Website), + _ => None, + }); + let limit = arguments + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as usize); + let request = search::NewsSearchRequest { + query, + region, + freshness, + order, + dir_desc: false, + limit, + }; + serde_json::to_string_pretty(&search::execute_news_search(&request, &token).await?)? + } + _ => format!("unsupported tool `{name}`"), + }; Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] })) } diff --git a/src/parser.rs b/src/parser.rs index 88cf09d..3849eb8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -4,13 +4,15 @@ //! by Kagi's web search endpoint. Also parses assistant profiles, threads, //! custom bangs, and lens details from their respective HTML pages. +use std::collections::HashSet; + use scraper::{Html, Selector}; use crate::error::KagiError; use crate::types::{ AssistantProfileDetails, AssistantProfileSummary, AssistantThreadSummary, CustomBangDetails, - CustomBangSummary, LensDetails, LensSummary, RedirectRuleDetails, RedirectRuleSummary, - SearchResult, + CustomBangSummary, LensDetails, LensSummary, NewsSearchCluster, NewsSearchResult, + RedirectRuleDetails, RedirectRuleSummary, SearchResult, }; /// Parse Kagi search results from HTML. @@ -52,6 +54,122 @@ pub fn parse_search_results(html: &str) -> Result, KagiError> Ok(results) } +/// Parse Kagi News-tab search results (the `/news?q=...` page). +/// +/// Returns clusters in document order. Items appearing inside a `.newsResultGroup` +/// share a cluster; items appearing outside any group become single-item clusters. +pub fn parse_news_search_results(html: &str) -> Result, KagiError> { + let document = Html::parse_document(html); + + let group_selector = selector(".newsResultGroup")?; + let item_selector = selector(".newsResultItem._0_SRI")?; + let combined_selector = selector(".newsResultGroup, .newsResultItem._0_SRI")?; + let title_link_selector = selector("a._0_TITLE")?; + let time_selector = selector(".newsResultTime")?; + let snippet_selector = selector(".newsResultContent")?; + let paywall_selector = selector(".paywall-icon")?; + let image_selector = selector(".newsResultImage img")?; + + // Track which items are inside a group so the unified walk can skip them. + let mut claimed = HashSet::new(); + for group in document.select(&group_selector) { + for item in group.select(&item_selector) { + claimed.insert(item.id()); + } + } + + let mut clusters = Vec::new(); + for element in document.select(&combined_selector) { + let is_group = element.value().classes().any(|c| c == "newsResultGroup"); + if is_group { + let items: Vec = element + .select(&item_selector) + .filter_map(|item| { + extract_news_item( + &item, + &title_link_selector, + &time_selector, + &snippet_selector, + &paywall_selector, + &image_selector, + ) + }) + .collect(); + if !items.is_empty() { + clusters.push(NewsSearchCluster { items }); + } + } else if !claimed.contains(&element.id()) + && let Some(item) = extract_news_item( + &element, + &title_link_selector, + &time_selector, + &snippet_selector, + &paywall_selector, + &image_selector, + ) + { + clusters.push(NewsSearchCluster { items: vec![item] }); + } + } + + Ok(clusters) +} + +fn extract_news_item( + element: &scraper::element_ref::ElementRef<'_>, + title_link_selector: &Selector, + time_selector: &Selector, + snippet_selector: &Selector, + paywall_selector: &Selector, + image_selector: &Selector, +) -> Option { + let link = element.select(title_link_selector).next()?; + let url = link.value().attr("href")?.trim().to_string(); + let title = link.text().collect::().trim().to_string(); + if title.is_empty() || url.is_empty() { + return None; + } + + let source = link + .value() + .attr("data-domain") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let time_relative = element + .select(time_selector) + .next() + .map(|node| node.text().collect::().trim().to_string()) + .filter(|value| !value.is_empty()); + + let snippet = element + .select(snippet_selector) + .next() + .map(|node| node.text().collect::().trim().to_string()) + .filter(|value| !value.is_empty()); + + let paywall = element.select(paywall_selector).next().is_some(); + + let image_url = element + .select(image_selector) + .next() + .and_then(|node| node.value().attr("src")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + Some(NewsSearchResult { + title, + url, + source, + time_relative, + snippet, + paywall, + image_url, + }) +} + /// Parses a list of assistant threads from the Kagi settings HTML. /// /// # Arguments @@ -653,7 +771,7 @@ mod tests { use super::{ parse_assistant_profile_form, parse_assistant_profile_list, parse_assistant_thread_list, parse_custom_bang_form, parse_custom_bang_list, parse_lens_form, parse_lens_list, - parse_redirect_form, parse_redirect_list, parse_search_results, + parse_news_search_results, parse_redirect_form, parse_redirect_list, parse_search_results, }; use crate::error::KagiError; @@ -1035,4 +1153,82 @@ mod tests { "^https://www.reddit.com|https://old.reddit.com" ); } + + #[test] + fn parses_news_search_clusters_and_ungrouped_items() { + let html = r#" + +
+
+ 2 hours ago +

+ Lead Story +

+
+
+
+
Lead story snippet.
+
+
+
+
+
+ 3 hours ago +

+ First in Cluster +

+
First snippet.
+
+
+ 4 hours ago +

+ Follower One +

+
+
+

+ Follower Two +

+
Follower two snippet.
+
+
+ + "#; + + let clusters = parse_news_search_results(html).expect("news parser should succeed"); + assert_eq!(clusters.len(), 2, "expected ungrouped + grouped cluster"); + + let ungrouped = &clusters[0]; + assert_eq!(ungrouped.items.len(), 1); + let lead = &ungrouped.items[0]; + assert_eq!(lead.title, "Lead Story"); + assert_eq!(lead.url, "https://www.cnn.com/lead"); + assert_eq!(lead.source.as_deref(), Some("cnn.com")); + assert_eq!(lead.time_relative.as_deref(), Some("2 hours ago")); + assert_eq!(lead.snippet.as_deref(), Some("Lead story snippet.")); + assert!(lead.paywall); + assert_eq!( + lead.image_url.as_deref(), + Some("https://img.example/lead.jpg") + ); + + let cluster = &clusters[1]; + assert_eq!(cluster.items.len(), 3); + assert_eq!(cluster.items[0].title, "First in Cluster"); + assert_eq!(cluster.items[0].source.as_deref(), Some("theguardian.com")); + assert_eq!( + cluster.items[1].time_relative.as_deref(), + Some("4 hours ago") + ); + assert!(cluster.items[1].snippet.is_none()); + assert_eq!(cluster.items[2].source.as_deref(), Some("reuters.com")); + assert!(!cluster.items[2].paywall); + } + + #[test] + fn news_search_parser_returns_empty_when_no_items() { + let html = "
nothing here
"; + let clusters = parse_news_search_results(html).expect("parser should succeed"); + assert!(clusters.is_empty()); + } } diff --git a/src/search.rs b/src/search.rs index 908486c..b4ff8ec 100644 --- a/src/search.rs +++ b/src/search.rs @@ -10,10 +10,11 @@ use tracing::debug; use crate::error::KagiError; use crate::http::{self, map_transport_error}; -use crate::parser::parse_search_results; -use crate::types::{SearchResponse, SearchResult}; +use crate::parser::{parse_news_search_results, parse_search_results}; +use crate::types::{NewsSearchResponse, SearchResponse, SearchResult}; const KAGI_SEARCH_PATH: &str = "/html/search"; +const KAGI_NEWS_SEARCH_PATH: &str = "/news"; const KAGI_API_SEARCH_PATH: &str = "/api/v0/search"; const DEBUG_BODY_PREVIEW_LIMIT: usize = 256; const UNAUTHENTICATED_MARKERS: [&str; 3] = [ @@ -414,6 +415,171 @@ pub async fn execute_search( Ok(SearchResponse { data }) } +/// Freshness window for News-tab search. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum NewsFreshness { + Day, + Week, + Month, +} + +impl NewsFreshness { + fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Week => "week", + Self::Month => "month", + } + } +} + +/// Sort order for News-tab search. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum NewsSearchOrder { + Default, + Recency, + Website, +} + +impl NewsSearchOrder { + fn as_str(self) -> &'static str { + match self { + Self::Default => "1", + Self::Recency => "2", + Self::Website => "3", + } + } +} + +#[derive(Debug, Clone, Serialize)] +/// Parameters for a News-tab search request (kagi.com/news). +pub struct NewsSearchRequest { + pub query: String, + pub region: Option, + pub freshness: Option, + pub order: Option, + pub dir_desc: bool, + pub limit: Option, +} + +impl NewsSearchRequest { + fn validate(&self) -> Result<(), KagiError> { + if self.query.trim().is_empty() { + return Err(KagiError::Config( + "search query cannot be empty".to_string(), + )); + } + if let Some(region) = self.region.as_deref() + && region.trim().is_empty() + { + return Err(KagiError::Config( + "search --region cannot be empty".to_string(), + )); + } + Ok(()) + } +} + +/// Executes a News-tab search request via session-token auth and returns parsed clusters. +/// +/// # Errors +/// Returns `KagiError::Auth` for missing or rejected session token, `KagiError::Network` +/// for transport/server errors, `KagiError::Parse` for invalid response markup. +pub async fn execute_news_search( + request: &NewsSearchRequest, + token: &str, +) -> Result { + if token.trim().is_empty() { + return Err(KagiError::Auth( + "missing Kagi session token (expected KAGI_SESSION_TOKEN)".to_string(), + )); + } + + request.validate()?; + + let client = build_client()?; + let query_params = build_news_search_query_params(request); + + let response = client + .get(http::kagi_url(KAGI_NEWS_SEARCH_PATH)) + .query(&query_params) + .header(header::COOKIE, format!("kagi_session={token}")) + .send() + .await + .map_err(map_transport_error)?; + + let body = match response.status() { + StatusCode::OK => response.text().await.map_err(|error| { + KagiError::Network(format!("failed to read response body: {error}")) + })?, + status @ (StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => { + let body = http::read_error_body(response, "news search").await; + return Err(KagiError::Auth(format!( + "invalid or expired Kagi session token for news search: HTTP {status}{}", + http::error_body_suffix(&body) + ))); + } + status if status.is_server_error() => { + let body = http::read_error_body(response, "news search").await; + return Err(KagiError::Network(format!( + "Kagi news search server error: HTTP {status}{}", + http::error_body_suffix(&body) + ))); + } + status => { + let body = http::read_error_body(response, "news search").await; + return Err(KagiError::Network(format!( + "unexpected Kagi news search response status: HTTP {status}{}", + http::error_body_suffix(&body) + ))); + } + }; + + if looks_unauthenticated(&body) { + return Err(KagiError::Auth( + "invalid or expired Kagi session token".to_string(), + )); + } + + let mut clusters = parse_news_search_results(&body)?; + + if let Some(limit) = request.limit { + let mut remaining = limit; + clusters.retain_mut(|cluster| { + if remaining == 0 { + return false; + } + if cluster.items.len() > remaining { + cluster.items.truncate(remaining); + } + remaining -= cluster.items.len(); + !cluster.items.is_empty() + }); + } + + Ok(NewsSearchResponse { + query: request.query.trim().to_string(), + clusters, + }) +} + +fn build_news_search_query_params(request: &NewsSearchRequest) -> Vec<(&'static str, String)> { + let mut params = vec![("q", request.query.trim().to_string())]; + if let Some(region) = trimmed_optional(request.region.as_deref()) { + params.push(("r", region.to_string())); + } + if let Some(freshness) = request.freshness { + params.push(("freshness", freshness.as_str().to_string())); + } + if let Some(order) = request.order { + params.push(("order", order.as_str().to_string())); + } + if request.dir_desc { + params.push(("dir", "desc".to_string())); + } + params +} + fn debug_body_preview(body: &str) -> &str { match body.char_indices().nth(DEBUG_BODY_PREVIEW_LIMIT) { Some((idx, _)) => &body[..idx], @@ -765,6 +931,42 @@ mod tests { assert_eq!(parsed.data[0].title, "Example"); } + #[test] + fn news_query_params_include_freshness_order_and_region() { + let request = NewsSearchRequest { + query: "iran".to_string(), + region: Some("us".to_string()), + freshness: Some(NewsFreshness::Day), + order: Some(NewsSearchOrder::Recency), + dir_desc: true, + limit: None, + }; + + let params = build_news_search_query_params(&request); + + assert!(params.contains(&("q", "iran".to_string()))); + assert!(params.contains(&("r", "us".to_string()))); + assert!(params.contains(&("freshness", "day".to_string()))); + assert!(params.contains(&("order", "2".to_string()))); + assert!(params.contains(&("dir", "desc".to_string()))); + } + + #[tokio::test] + async fn execute_news_search_requires_session_token() { + let request = NewsSearchRequest { + query: "iran".to_string(), + region: None, + freshness: None, + order: None, + dir_desc: false, + limit: None, + }; + let err = execute_news_search(&request, "") + .await + .expect_err("empty token should fail"); + assert!(matches!(err, KagiError::Auth(_))); + } + #[test] fn formats_search_api_error_suffix_from_error_payload() { let raw = r#"{ diff --git a/src/types.rs b/src/types.rs index 2b22567..56c5a5c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -37,6 +37,36 @@ pub struct SearchResponse { pub data: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A single article in the News tab of Kagi search. +pub struct NewsSearchResult { + pub title: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_relative: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snippet: Option, + #[serde(default)] + pub paywall: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A cluster of news articles covering the same story. +pub struct NewsSearchCluster { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Response from the News tab of Kagi search. +pub struct NewsSearchResponse { + pub query: String, + pub clusters: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// Metadata returned with most Kagi API responses (request ID, node, latency). pub struct ApiMeta { @@ -225,7 +255,7 @@ pub struct NewsStoriesPayload { pub timestamp: u64, pub stories: Vec, #[serde(rename = "totalStories")] - pub total_stories: String, + pub total_stories: u64, #[serde(default)] pub domains: Vec, #[serde(rename = "readCount")] @@ -238,7 +268,7 @@ pub struct NewsStoriesResponse { pub latest_batch: NewsLatestBatch, pub category: NewsResolvedCategory, pub stories: Vec, - pub total_stories: String, + pub total_stories: u64, #[serde(default)] pub domains: Vec, pub read_count: u64, diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index f3666ca..4dec563 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -178,7 +178,7 @@ fn news_stories() -> Value { "url": "https://example.com/rust-release" } ], - "totalStories": "1", + "totalStories": 1, "domains": [], "readCount": 10 }) @@ -454,6 +454,165 @@ fn summarize_url_command_prints_structured_json() { assert_eq!(body["data"]["output"], "A concise summary."); } +fn news_search_html_fixture() -> &'static str { + r#" +
+ 2 hours ago +

+ Lead Story +

+
+
Lead snippet.
+
+
+
+ 3 hours ago +

+ First in Cluster +

+
First cluster snippet.
+
+
+ 4 hours ago +

+ Follower +

+
+
+ "# +} + +#[test] +fn search_news_returns_clustered_json() { + let server = MockServer::start(); + let _news = server.mock(|when, then| { + when.method(GET) + .path("/news") + .query_param("q", "iran") + .query_param("freshness", "day") + .query_param("order", "2") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(news_search_html_fixture()); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = session_env(&server); + let output = run_kagi( + &[ + "search", "iran", "--news", "--time", "day", "--order", "recency", "--format", "json", + ], + &env_refs(&env), + tempdir.path(), + ); + + assert_success(&output); + let body: Value = serde_json::from_slice(&output.stdout).expect("json output should parse"); + assert_eq!(body["query"], "iran"); + let clusters = body["clusters"].as_array().expect("clusters array"); + assert_eq!(clusters.len(), 2, "expected ungrouped + grouped clusters"); + assert_eq!(clusters[0]["items"][0]["title"], "Lead Story"); + assert_eq!(clusters[0]["items"][0]["source"], "cnn.com"); + assert_eq!(clusters[0]["items"][0]["time_relative"], "2 hours ago"); + assert_eq!(clusters[0]["items"][0]["paywall"], true); + let cluster_items = clusters[1]["items"].as_array().expect("cluster items"); + assert_eq!(cluster_items.len(), 2); + assert_eq!(cluster_items[1]["source"], "bbc.com"); + assert_eq!(cluster_items[1]["time_relative"], "4 hours ago"); +} + +#[test] +fn search_news_local_cache_reuses_cached_response() { + let server = MockServer::start(); + let news = server.mock(|when, then| { + when.method(GET) + .path("/news") + .query_param("q", "iran") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(news_search_html_fixture()); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let cache_dir = tempdir.path().join("cache"); + let cache_dir_value = cache_dir.to_string_lossy().to_string(); + let mut env = session_env(&server); + env.push(("KAGI_CACHE_DIR", cache_dir_value)); + + let first = run_kagi( + &[ + "search", + "iran", + "--news", + "--local-cache", + "--format", + "json", + ], + &env_refs(&env), + tempdir.path(), + ); + assert_success(&first); + + let second = run_kagi( + &[ + "search", + "iran", + "--news", + "--local-cache", + "--format", + "json", + ], + &env_refs(&env), + tempdir.path(), + ); + assert_success(&second); + + news.assert_calls(1); + assert_eq!(first.stdout, second.stdout); +} + +#[test] +fn search_news_rejects_lens_combination() { + let tempdir = TempDir::new().expect("tempdir"); + let env = [("KAGI_SESSION_TOKEN", "test-session")]; + let output = run_kagi( + &["search", "iran", "--news", "--lens", "1"], + &env, + tempdir.path(), + ); + assert!( + !output.status.success(), + "expected non-zero exit for --news --lens" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--lens"), + "expected --lens conflict in stderr: {stderr}" + ); +} + +#[test] +fn search_news_rejects_time_year() { + let tempdir = TempDir::new().expect("tempdir"); + let env = [("KAGI_SESSION_TOKEN", "test-session")]; + let output = run_kagi( + &["search", "iran", "--news", "--time", "year"], + &env, + tempdir.path(), + ); + assert!( + !output.status.success(), + "expected non-zero exit for --news --time year" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--time year"), + "expected --time year rejection in stderr: {stderr}" + ); +} + #[test] fn news_command_resolves_category_and_prints_json() { let server = MockServer::start(); @@ -677,3 +836,132 @@ fn mcp_initialize_returns_server_info() { assert_eq!(response["id"], 1); assert_eq!(response["result"]["serverInfo"]["name"], "kagi-cli"); } + +#[test] +fn mcp_tools_list_includes_news() { + let tempdir = TempDir::new().expect("tempdir"); + let output = run_kagi_with_stdin( + &["mcp"], + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n", + &[], + tempdir.path(), + ); + + assert_success(&output); + let response: Value = serde_json::from_slice(&output.stdout).expect("mcp json parses"); + let tools = response["result"]["tools"].as_array().expect("tools array"); + assert!( + tools.iter().any(|tool| tool["name"] == "kagi_news"), + "expected kagi_news in tools list, got {tools:?}" + ); +} + +#[test] +fn mcp_news_tool_call_returns_stories() { + let server = MockServer::start(); + let _latest = server.mock(|when, then| { + when.method(GET) + .path("/api/batches/latest") + .query_param("lang", "en"); + then.status(200) + .header("content-type", "application/json") + .json_body(news_latest_batch()); + }); + let _metadata = server.mock(|when, then| { + when.method(GET).path("/api/categories/metadata"); + then.status(200) + .header("content-type", "application/json") + .json_body(news_category_metadata()); + }); + let _categories = server.mock(|when, then| { + when.method(GET) + .path("/api/batches/batch-1/categories") + .query_param("lang", "en"); + then.status(200) + .header("content-type", "application/json") + .json_body(news_batch_categories()); + }); + let _stories = server.mock(|when, then| { + when.method(GET) + .path("/api/batches/batch-1/categories/category-1/stories") + .query_param("limit", "3") + .query_param("lang", "en"); + then.status(200) + .header("content-type", "application/json") + .json_body(news_stories()); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = test_env(&server); + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "kagi_news", + "arguments": { "category": "tech", "lang": "en", "limit": 3 } + } + }); + let mut stdin = serde_json::to_string(&request).expect("request serializes"); + stdin.push('\n'); + + let output = run_kagi_with_stdin(&["mcp"], &stdin, &env_refs(&env), tempdir.path()); + + assert_success(&output); + let response: Value = serde_json::from_slice(&output.stdout).expect("mcp json parses"); + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("text content"); + let body: Value = serde_json::from_str(text).expect("inner json parses"); + assert_eq!(body["category"]["category_name"], "Tech"); + assert_eq!(body["stories"][0]["title"], "Rust ships new release"); +} + +#[test] +fn mcp_news_search_tool_call_returns_clusters() { + let server = MockServer::start(); + let _news = server.mock(|when, then| { + when.method(GET) + .path("/news") + .query_param("q", "iran") + .query_param("freshness", "day") + .query_param("order", "2") + .header("cookie", "kagi_session=test-session"); + then.status(200) + .header("content-type", "text/html") + .body(news_search_html_fixture()); + }); + + let tempdir = TempDir::new().expect("tempdir"); + let env = session_env(&server); + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "kagi_news_search", + "arguments": { + "query": "iran", + "freshness": "day", + "order": "recency" + } + } + }); + let mut stdin = serde_json::to_string(&request).expect("request serializes"); + stdin.push('\n'); + + let output = run_kagi_with_stdin(&["mcp"], &stdin, &env_refs(&env), tempdir.path()); + + assert_success(&output); + let response: Value = serde_json::from_slice(&output.stdout).expect("mcp json parses"); + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("text content"); + let body: Value = serde_json::from_str(text).expect("inner json parses"); + assert_eq!(body["query"], "iran"); + let clusters = body["clusters"].as_array().expect("clusters array"); + assert_eq!(clusters.len(), 2); + assert_eq!(clusters[0]["items"][0]["title"], "Lead Story"); + assert_eq!(clusters[0]["items"][0]["paywall"], true); + assert_eq!(clusters[1]["items"].as_array().unwrap().len(), 2); +}