mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
fix(mcp): hand a call's warnings to the agent
idx-core reports what it works around (history falling back to yahoo when Stockbit refuses the session, a stale cache served after a failed fetch, a movements query cut short) as warnings on stderr. An agent never reads idx-mcp's stderr, so it saw `source: "yahoo"` or `stale: true` with no reason. - Each tool call collects its warnings with runtime::on_warning and returns them as `warnings` on the answer or the error, when there are any. - The stale-cache warning names the failure behind it. - The mock's session API 403 is the real session API's block message; the public page's block names IDX_PROVIDER=msn as well as `--provider msn`. Goldens: error-intraday-blocked (json, table) carry the reworded block hint. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
parent
e739ee3c20
commit
9b46161566
10 changed files with 145 additions and 18 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- 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"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- 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
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,10 @@ impl MockStockbitProvider {
|
||||||
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
"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) {
|
if let Some(err) = self.failure(symbol) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,11 +257,7 @@ fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||||
Err(IdxError::SymbolNotFound(subject.to_string()))
|
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||||
}
|
}
|
||||||
429 => Err(IdxError::RateLimited),
|
429 => Err(IdxError::RateLimited),
|
||||||
403 => Err(IdxError::UpstreamBlocked(
|
403 => Err(blocked()),
|
||||||
"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(),
|
|
||||||
)),
|
|
||||||
status => Err(IdxError::Http(format!(
|
status => Err(IdxError::Http(format!(
|
||||||
"stockbit api: HTTP {status}{}",
|
"stockbit api: HTTP {status}{}",
|
||||||
message(&reply.body)
|
message(&reply.body)
|
||||||
|
|
@ -497,6 +493,16 @@ fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError {
|
||||||
match status {
|
match status {
|
||||||
403 => IdxError::UpstreamBlocked(
|
403 => IdxError::UpstreamBlocked(
|
||||||
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
"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 \
|
block. Try again later or from another network; the msn provider (`--provider msn`, \
|
||||||
without Stockbit"
|
or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ where
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||||
runtime::warn(format!(
|
runtime::warn(format!(
|
||||||
"network failed, serving stale cache for {}",
|
"network failed ({err}), serving stale cache for {}",
|
||||||
spec.subject
|
spec.subject
|
||||||
));
|
));
|
||||||
return Ok(hit(stale, true));
|
return Ok(hit(stale, true));
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
|
||||||
//! cache and ownership database.
|
//! cache and ownership database.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
|
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::market::{ScreenFilter, ScreenQuery, ScreenRegion};
|
||||||
use idx_core::movements::types::MovementQuery;
|
use idx_core::movements::types::MovementQuery;
|
||||||
use idx_core::ownership::types::ChangeRow;
|
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::router::tool::ToolRouter;
|
||||||
use rmcp::handler::server::wrapper::Parameters;
|
use rmcp::handler::server::wrapper::Parameters;
|
||||||
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
|
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 \
|
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}; \
|
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 \
|
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 \
|
exist. Percentages ending in _bps are basis points (5494 = 54.94%). Errors carry a \
|
||||||
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
stable code, e.g. SYMBOLNOTFOUND, RATELIMITED, INVALIDINPUT.";
|
||||||
|
|
@ -745,13 +749,43 @@ where
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
||||||
let result = tokio::task::spawn_blocking(move || f().and_then(to_json))
|
let (result, warnings) = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
// idx-core warns on stderr, which the agent never sees; what this
|
||||||
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
|
// call warns about (a fallback, a stale cache, a partial answer)
|
||||||
Ok(match result {
|
// goes into its answer instead. The call runs on this thread.
|
||||||
Ok(value) => CallToolResult::structured(value),
|
let warnings = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||||
Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))),
|
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<String>) -> 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.
|
/// Per-symbol failures as error bodies carrying their symbol.
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// 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
|
/// page takes the cookie the way the browser would, and stockbit_status
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,9 @@ Key decisions:
|
||||||
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
- **`Fetched<T>` envelope** for live data: `{ data, source, fetched_at, stale }`,
|
||||||
so agents and API clients can see freshness. idx-core still emits the
|
so agents and API clients can see freshness. idx-core still emits the
|
||||||
stale-cache warnings through `runtime` (which `set_quiet` silences) so CLI
|
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` /
|
- **`CachePolicy`**: `Default | Offline | Refresh` replaces the `--offline` /
|
||||||
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
`--no-cache` flag handling now spread through `cli/stocks.rs`.
|
||||||
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
- **Explicit configuration.** `CoreConfig::load()` is the one place idx-core reads
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@ Tools that fetch live data return:
|
||||||
failed. Ownership tools return their data directly, from the local
|
failed. Ownership tools return their data directly, from the local
|
||||||
database.
|
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
|
Errors come back as a tool result with `isError` set and the CLI's error
|
||||||
body, so the agent can decide whether to retry:
|
body, so the agent can decide whether to retry:
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue