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:
Rasyidan Akbar F. 2026-11-04 19:26:10 +07:00
commit 9b46161566
10 changed files with 145 additions and 18 deletions

View file

@ -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.";
@ -745,13 +749,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::<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
.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.