Merge pull request #81 from 0xrsydn/fix/mcp-warnings

fix(mcp): hand a call's warnings to the agent
This commit is contained in:
dogfiles 2026-11-05 17:21:47 +07:00 • committed by GitHub
commit c2336ea52f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 151 additions and 20 deletions

View file

@ -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"}

View file

@ -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

View file

@ -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);
} }

View file

@ -272,11 +272,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)
@ -512,6 +508,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(),
)
}
/// Whether `path` is one idx reads; `..` never climbs out of a prefix. /// Whether `path` is one idx reads; `..` never climbs out of a prefix.
fn is_read_path(path: &str) -> bool { fn is_read_path(path: &str) -> bool {
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..") READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")

View file

@ -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()),

View file

@ -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));

View file

@ -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.";
@ -747,15 +751,45 @@ 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 || {
// 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::<String>::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 .await
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?; .map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
Ok(match result { Ok(match result {
Ok(value) => CallToolResult::structured(value), Ok(value) => CallToolResult::structured(with_warnings(value, warnings)),
Err(err) => CallToolResult::structured_error(Value::Object(error_body(&err))), 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.
fn failures_json(failures: &[(String, IdxError)]) -> Vec<Value> { fn failures_json(failures: &[(String, IdxError)]) -> Vec<Value> {
failures failures

View file

@ -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

View file

@ -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

View file

@ -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 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 starts with `--allow-sync`. Results that come from a live provider are wrapped
as `{data, source, fetched_at, stale}`; failures return `{error, code, 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 EOF

View file

@ -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:

View file

@ -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 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 starts with `--allow-sync`. Results that come from a live provider are wrapped
as `{data, source, fetched_at, stale}`; failures return `{error, code, 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` ## `analyze_bandar`