diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 9c4a1b8..4bab5c7 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -144,9 +144,12 @@ pub struct ChangesParams { /// Only this ticker's changes. #[serde(default)] pub ticker: Option, - /// Maximum rows returned (default 200, at most 2000); `total` reports the full count. + /// Rows per page (default 200, at most 300); `total` reports the full count. #[serde(default)] pub limit: Option, + /// Rows to skip, for the next page: the previous response's `next_offset`. + #[serde(default)] + pub offset: Option, } #[derive(Debug, Deserialize, JsonSchema)] diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 558d0ed..d66819b 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -2,12 +2,15 @@ //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! cache and ownership database. +use std::collections::BTreeMap; + use idx_core::analysis::fundamental::FundamentalReport; use idx_core::config::CoreConfig; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; +use idx_core::ownership::types::ChangeRow; use idx_core::{CachePolicy, Fetched, Idx}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; @@ -32,7 +35,9 @@ const CHANGES_LIMIT: usize = 200; const MAX_SYMBOLS: usize = 20; const MAX_SCREEN_LIMIT: usize = 200; const MAX_SEARCH_LIMIT: usize = 100; -const MAX_CHANGES_LIMIT: usize = 2000; +// Pages above a few hundred rows (~40 KB) overflow some agents' tool output. +const MAX_CHANGES_LIMIT: usize = 300; +const CHANGES_TOP_TICKERS: usize = 10; /// Tool calls running at once; the rest wait their turn. static CALL_SLOTS: Semaphore = Semaphore::const_new(4); @@ -294,8 +299,10 @@ with concentration metrics (top1, top3, HHI, free float)." } #[tool( - description = "Holder changes (new, exit, increase, decrease) between two KSEI \ -release dates, optionally for one ticker." + description = "Holder changes (new, exited, increased, decreased) between two KSEI \ +release dates, optionally for one ticker. Rows come largest change first, one page at a time: \ +`summary` covers all of them (counts by type, tickers with the most changes), and \ +`next_offset` is the `offset` for the next page, or null after the last." )] async fn get_ownership_changes(&self, params: Parameters) -> ToolResult { let ChangesParams { @@ -303,13 +310,39 @@ release dates, optionally for one ticker." to, ticker, limit, + offset, } = params.0; self.local(move |investor| { - let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT)?; + let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT).map_err( + |error| match error { + IdxError::InvalidInput(message) => IdxError::InvalidInput(format!( + "{message}; page with `offset`, or use `summary` for an overview" + )), + other => other, + }, + )?; + let offset = offset.unwrap_or(0); let mut changes = investor.changes(&from, &to, ticker.as_deref())?; + changes.sort_by(|a, b| { + b.delta_bps + .abs() + .cmp(&a.delta_bps.abs()) + .then_with(|| a.ticker_code.cmp(&b.ticker_code)) + .then_with(|| a.entity_name.cmp(&b.entity_name)) + }); let total = changes.len(); - changes.truncate(limit); - Ok(json!({ "from": from, "to": to, "total": total, "changes": changes })) + let summary = changes_summary(&changes); + let page: Vec<_> = changes.into_iter().skip(offset).take(limit).collect(); + let next_offset = (offset + page.len() < total).then_some(offset + page.len()); + Ok(json!({ + "from": from, + "to": to, + "total": total, + "summary": summary, + "offset": offset, + "next_offset": next_offset, + "changes": page, + })) }) .await } @@ -455,6 +488,33 @@ fn dedup_in_order(values: &mut Vec) { }); } +/// Counts by change type and the tickers with the most changes, so an agent +/// can answer an overview question without paging through every row. +fn changes_summary(changes: &[ChangeRow]) -> Value { + let mut by_type: BTreeMap = BTreeMap::new(); + let mut by_ticker: BTreeMap<&str, usize> = BTreeMap::new(); + for row in changes { + let kind = serde_json::to_value(row.change_type) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_default(); + *by_type.entry(kind).or_default() += 1; + *by_ticker.entry(row.ticker_code.as_str()).or_default() += 1; + } + let tickers = by_ticker.len(); + let mut top: Vec<_> = by_ticker.into_iter().collect(); + top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); + top.truncate(CHANGES_TOP_TICKERS); + json!({ + "by_type": by_type, + "tickers": tickers, + "top_tickers": top + .into_iter() + .map(|(ticker, changes)| json!({ "ticker": ticker, "changes": changes })) + .collect::>(), + }) +} + fn bounded_limit( field: &str, value: Option, diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 9287ed4..8464d38 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -115,7 +115,7 @@ "name": "get_investor_holdings" }, { - "description": "Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker.", + "description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -125,7 +125,17 @@ }, "limit": { "default": null, - "description": "Maximum rows returned (default 200, at most 2000); `total` reports the full count.", + "description": "Rows per page (default 200, at most 300); `total` reports the full count.", + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "offset": { + "default": null, + "description": "Rows to skip, for the next page: the previous response's `next_offset`.", "format": "uint", "minimum": 0, "type": [ diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index e7d07b4..c35ef5a 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -346,6 +346,44 @@ fn ownership_tools_read_the_local_database() { ); assert_eq!(limited["total"], total); assert_eq!(limited["changes"].as_array().unwrap().len(), 1); + assert_eq!(limited["next_offset"], 1); + + // Pages cover every row exactly once, largest change first. + let mut paged = Vec::new(); + let mut offset = Some(0); + while let Some(next) = offset { + let page = client.ok( + "get_ownership_changes", + json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 2, "offset": next}), + ); + paged.extend(page["changes"].as_array().unwrap().iter().cloned()); + offset = page["next_offset"].as_u64(); + } + assert_eq!(paged.len() as u64, total); + assert_eq!(&paged, all["changes"].as_array().unwrap()); + let deltas: Vec<_> = paged + .iter() + .map(|row| row["delta_bps"].as_i64().unwrap().abs()) + .collect(); + assert!( + deltas.windows(2).all(|pair| pair[0] >= pair[1]), + "{deltas:?}" + ); + + let by_type = all["summary"]["by_type"].as_object().unwrap(); + assert_eq!( + by_type.values().map(|n| n.as_u64().unwrap()).sum::(), + total + ); + assert!(all["summary"]["tickers"].as_u64().unwrap() >= 1, "{all}"); + + let (is_error, body) = client.call( + "get_ownership_changes", + json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 301}), + ); + assert!(is_error, "{body}"); + assert_eq!(body["code"], "INVALIDINPUT"); + assert!(body.to_string().contains("offset"), "{body}"); let one = client.ok( "get_ownership_changes", diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index d34a6e7..d4e06b1 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -45,7 +45,7 @@ body, so the agent can decide whether to retry: | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | | `screen_stocks` `limit` | 200 | | `search_investors` `limit` | 100 | -| `get_ownership_changes` `limit` | 2000 (default 200) | +| `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` | | concurrent tool calls | 4 | ## Ownership data diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 16e57fa..55bf06b 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -46,12 +46,13 @@ Every IDX holding (above 1%) of one investor, matched by name. ## `get_ownership_changes` -Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker. +Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last. | Input | Type | Required | Description | | --- | --- | --- | --- | | `from` | string | yes | Earlier release date, YYYY-MM-DD (see ownership_status). | -| `limit` | integer | no | Maximum rows returned (default 200, at most 2000); `total` reports the full count. | +| `limit` | integer | no | Rows per page (default 200, at most 300); `total` reports the full count. | +| `offset` | integer | no | Rows to skip, for the next page: the previous response's `next_offset`. | | `ticker` | string | no | Only this ticker's changes. | | `to` | string | yes | Later release date, YYYY-MM-DD. |