diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden index 402cc2b..7554cf0 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.json.golden @@ -1,4 +1,4 @@ exit: 4 --- stdout --- stderr -{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"} +{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"} diff --git a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden index fd793c7..d7b4759 100644 --- a/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden +++ b/crates/idx-cli/tests/golden/error-intraday-blocked.table.golden @@ -1,4 +1,4 @@ exit: 4 --- stdout --- stderr -Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit +Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index 9dbf60a..8477e7e 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -187,6 +187,10 @@ impl MockStockbitProvider { "Stockbit rejected the session; run `idx stockbit login` again".to_string(), )); } + // The session API's own block, as `exodus::fetch` reports it. + if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") { + return Err(stockbit::exodus::blocked()); + } if let Some(err) = self.failure(symbol) { return Err(err); } diff --git a/crates/idx-core/src/api/stockbit/exodus.rs b/crates/idx-core/src/api/stockbit/exodus.rs index 8e3e60b..a872162 100644 --- a/crates/idx-core/src/api/stockbit/exodus.rs +++ b/crates/idx-core/src/api/stockbit/exodus.rs @@ -272,11 +272,7 @@ fn classify(reply: Reply, subject: &str) -> Result { Err(IdxError::SymbolNotFound(subject.to_string())) } 429 => Err(IdxError::RateLimited), - 403 => Err(IdxError::UpstreamBlocked( - "exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \ - retry a block. Try again later or from another network" - .to_string(), - )), + 403 => Err(blocked()), status => Err(IdxError::Http(format!( "stockbit api: HTTP {status}{}", message(&reply.body) @@ -512,6 +508,16 @@ fn trim_to_period(mut bars: Vec, period: &Period) -> Vec { } } +/// A 403 from the CDN in front of the session API: a block, not a refused +/// session, so nothing is retried. +pub(crate) fn blocked() -> IdxError { + IdxError::UpstreamBlocked( + "exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \ + retry a block. Try again later or from another network" + .to_string(), + ) +} + /// Whether `path` is one idx reads; `..` never climbs out of a prefix. fn is_read_path(path: &str) -> bool { READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..") diff --git a/crates/idx-core/src/api/stockbit/parse.rs b/crates/idx-core/src/api/stockbit/parse.rs index 9ecbd5c..17a0b90 100644 --- a/crates/idx-core/src/api/stockbit/parse.rs +++ b/crates/idx-core/src/api/stockbit/parse.rs @@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError { match status { 403 => IdxError::UpstreamBlocked( "stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \ - block. Try again later or from another network; `--provider msn` serves quotes \ - without Stockbit" + block. Try again later or from another network; the msn provider (`--provider msn`, \ + or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit" .to_string(), ), 404 => IdxError::SymbolNotFound(resolved.to_string()), diff --git a/crates/idx-core/src/client.rs b/crates/idx-core/src/client.rs index 57e6333..9fe1940 100644 --- a/crates/idx-core/src/client.rs +++ b/crates/idx-core/src/client.rs @@ -199,7 +199,7 @@ where Err(err) => { if use_cache && let Some(stale) = cache.any::(spec.bucket, spec.key)? { runtime::warn(format!( - "network failed, serving stale cache for {}", + "network failed ({err}), serving stale cache for {}", spec.subject )); return Ok(hit(stale, true)); diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 2a08cb4..2fa6dd0 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -2,7 +2,9 @@ //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! cache and ownership database. +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; use idx_core::analysis::BandarQuery; use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP}; @@ -18,7 +20,7 @@ use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; use idx_core::movements::types::MovementQuery; use idx_core::ownership::types::ChangeRow; -use idx_core::{CachePolicy, Fetched, Idx}; +use idx_core::{CachePolicy, Fetched, Idx, runtime}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig}; @@ -57,7 +59,9 @@ static CALL_SLOTS: Semaphore = Semaphore::const_new(4); const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \ tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \ -stale=true means an expired cache entry was served. Ownership tools read a local KSEI \ +stale=true means an expired cache entry was served. An answer (or error) may carry \ +`warnings`: what idx worked around, such as a refused Stockbit session that made history \ +fall back to yahoo, or the failure behind a stale entry; pass on any that need the user. Ownership tools read a local KSEI \ database of holders above 1%: call ownership_status first to see which release dates \ exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \ stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT."; @@ -747,13 +751,43 @@ where .acquire() .await .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - let result = tokio::task::spawn_blocking(move || f().and_then(to_json)) - .await - .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; - Ok(match result { - Ok(value) => CallToolResult::structured(value), - Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))), + let (result, warnings) = tokio::task::spawn_blocking(move || { + // idx-core warns on stderr, which the agent never sees; what this + // call warns about (a fallback, a stale cache, a partial answer) + // goes into its answer instead. The call runs on this thread. + let warnings = Rc::new(RefCell::new(Vec::::new())); + let sink = { + let warnings = warnings.clone(); + runtime::on_warning(move |message| { + let mut warnings = warnings.borrow_mut(); + if !warnings.iter().any(|seen| seen == message) { + warnings.push(message.to_string()); + } + }) + }; + let result = f().and_then(to_json); + drop(sink); + (result, warnings.take()) }) + .await + .map_err(|err| ErrorData::internal_error(err.to_string(), None))?; + Ok(match result { + Ok(value) => CallToolResult::structured(with_warnings(value, warnings)), + Err(err) => CallToolResult::structured_error(with_warnings( + Value::Object(error_body(&err)), + warnings, + )), + }) +} + +/// Adds `warnings` to an answer when the call had any. +fn with_warnings(mut value: Value, warnings: Vec) -> Value { + if !warnings.is_empty() + && let Some(object) = value.as_object_mut() + { + object.insert("warnings".into(), json!(warnings)); + } + value } /// Per-symbol failures as error bodies carrying their symbol. diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index b705104..fa27569 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -374,6 +374,72 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() { ); } +/// What idx works around reaches the agent as `warnings`, since it never +/// sees idx-mcp's stderr: a refused session that made history fall back to +/// yahoo, and a movements query cut short. A clean answer has none. +#[test] +fn workarounds_reach_the_agent_as_warnings() { + let mut client = Client::start_with_env( + &[], + false, + &[ + TEST_SESSION, + ("IDX_PROVIDER", "stockbit"), + ("IDX_MOCK_STOCKBIT_STATUS", "401"), + ], + ); + let history = client.ok( + "get_price_history", + json!({"symbol": "BBCA", "period": "1mo"}), + ); + assert_eq!(history["source"], "yahoo"); + let warnings = history["warnings"].as_array().expect("warnings"); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0] + .as_str() + .unwrap() + .contains("Stockbit rejected the session") + && warnings[0] + .as_str() + .unwrap() + .contains("falls back to yahoo"), + "{warnings:?}" + ); + let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"})); + assert!( + analysis["warnings"][0] + .as_str() + .unwrap() + .contains("falls back to yahoo") + ); + // The next call starts with none of the last one's warnings. + let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"})); + assert!(is_error && body["code"] == "AUTHERROR", "{body}"); + assert!(body.get("warnings").is_none(), "{body}"); + + let mut client = Client::start_with_env( + &[], + false, + &[TEST_SESSION, ("IDX_MOCK_MOVEMENTS_FAIL_PAGE", "2")], + ); + let partial = client.ok( + "get_shareholder_movements", + json!({"symbol": "BBCA", "pages": 3}), + ); + assert_eq!(partial["data"]["incomplete"], "provider rate limited"); + assert!( + partial["warnings"][0] + .as_str() + .unwrap() + .contains("showing the 8 rows read before it") + ); + + let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]); + let clean = client.ok("get_shareholder_movements", json!({"symbol": "BBCA"})); + assert!(clean.get("warnings").is_none(), "{clean}"); +} + /// The sign-in tool answers at once. With no browser to open (the mock /// provider never starts one), auto falls back to the page and says why; the /// page takes the cookie the way the browser would, and stockbit_status diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f9e8b20..ba75a77 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -93,7 +93,9 @@ Key decisions: - **`Fetched` envelope** for live data: `{ data, source, fetched_at, stale }`, so agents and API clients can see freshness. idx-core still emits the stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI - stderr stays identical. + stderr stays identical. idx-mcp also catches a call's warnings with + `runtime::on_warning` (the call runs on one blocking thread) and returns + them as `warnings`, since an agent never reads the server's stderr. - **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` / `--no-cache` flag handling now spread through `cli/stocks.rs`. - **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads diff --git a/web/scripts/gen-mcp-tools.sh b/web/scripts/gen-mcp-tools.sh index fefb17f..ad687e7 100755 --- a/web/scripts/gen-mcp-tools.sh +++ b/web/scripts/gen-mcp-tools.sh @@ -19,7 +19,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. EOF diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index d45736d..940e93a 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -33,6 +33,21 @@ Tools that fetch live data return: failed. Ownership tools return their data directly, from the local database. +What `idx` prints as a warning in a terminal comes back in `warnings`, on an +answer or an error, when there was any. For example, history that fell back to +Yahoo because Stockbit refused your session says so, and a stale answer names +the failure behind it: + +```json +{ + "data": [{ "date": "2026-11-03", "close": 9875, "...": "..." }], + "source": "yahoo", + "fetched_at": "2026-11-04T03:20:11Z", + "stale": false, + "warnings": ["Stockbit rejected the session; run `idx stockbit login` again; history falls back to yahoo meanwhile"] +} +``` + Errors come back as a tool result with `isError` set and the CLI's error body, so the agent can decide whether to retry: diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 43ec637..e6dbb65 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -8,7 +8,9 @@ description: Every idx-mcp tool and its inputs, generated from the server's tool idx-mcp serves these tools. `ownership_sync` is listed only when the server starts with `--allow-sync`. Results that come from a live provider are wrapped as `{data, source, fetched_at, stale}`; failures return `{error, code, -message}` with `isError` set. +message}` with `isError` set. Either may carry `warnings`, what idx worked +around (such as a history fallback or a stale cache entry), when there was +any. ## `analyze_bandar`