mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
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
893 lines
32 KiB
Rust
893 lines
32 KiB
Rust
//! Drives the idx-mcp binary over stdio JSON-RPC with the mock provider and a
|
|
//! fixture-built ownership database.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
|
|
|
use idx_core::config::CoreConfig;
|
|
use idx_core::investor::{ImportSource, Investor};
|
|
use serde_json::{Value, json};
|
|
|
|
const TOOLS: [&str; 21] = [
|
|
"analyze_bandar",
|
|
"analyze_stock",
|
|
"compare_stocks",
|
|
"get_broker_summary",
|
|
"get_company",
|
|
"get_corporate_actions",
|
|
"get_corporate_calendar",
|
|
"get_foreign_flow",
|
|
"get_intraday",
|
|
"get_investor_holdings",
|
|
"get_orderbook",
|
|
"get_ownership_changes",
|
|
"get_price_history",
|
|
"get_quotes",
|
|
"get_shareholder_movements",
|
|
"get_shareholders",
|
|
"ownership_status",
|
|
"screen_stocks",
|
|
"search_investors",
|
|
"stockbit_login",
|
|
"stockbit_status",
|
|
];
|
|
|
|
/// An unsigned test token expiring in 2100, as a seeded Stockbit session.
|
|
const TEST_SESSION: (&str, &str) = (
|
|
"IDX_STOCKBIT_REFRESH_TOKEN",
|
|
"eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln",
|
|
);
|
|
|
|
struct Client {
|
|
child: Child,
|
|
stdin: ChildStdin,
|
|
stdout: BufReader<ChildStdout>,
|
|
next_id: u64,
|
|
_home: tempdir::Dir,
|
|
}
|
|
|
|
impl Client {
|
|
fn start(args: &[&str], seed: bool) -> Self {
|
|
Self::start_with_env(args, seed, &[])
|
|
}
|
|
|
|
fn start_with_env(args: &[&str], seed: bool, env: &[(&str, &str)]) -> Self {
|
|
let home = tempdir::Dir::new();
|
|
if seed {
|
|
seed_ownership_db(&home.path().join("data/idx/ownership.db"));
|
|
}
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_idx-mcp"))
|
|
.args(args)
|
|
// The mock provider reads `tests/fixtures/...` relative to here.
|
|
.current_dir(core_dir())
|
|
.env_clear()
|
|
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
|
|
.env("XDG_CONFIG_HOME", home.path().join("config"))
|
|
.env("XDG_CACHE_HOME", home.path().join("cache"))
|
|
.env("XDG_DATA_HOME", home.path().join("data"))
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.envs(env.iter().copied())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn idx-mcp");
|
|
let stdin = child.stdin.take().unwrap();
|
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
|
let mut client = Self {
|
|
child,
|
|
stdin,
|
|
stdout,
|
|
next_id: 1,
|
|
_home: home,
|
|
};
|
|
let init = client.request(
|
|
"initialize",
|
|
json!({
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test", "version": "0"}
|
|
}),
|
|
);
|
|
assert_eq!(init["serverInfo"]["name"], "idx-mcp");
|
|
client.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
|
client
|
|
}
|
|
|
|
fn send(&mut self, message: Value) {
|
|
writeln!(self.stdin, "{message}").expect("write request");
|
|
self.stdin.flush().expect("flush request");
|
|
}
|
|
|
|
fn request(&mut self, method: &str, params: Value) -> Value {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
|
loop {
|
|
let mut line = String::new();
|
|
let read = self.stdout.read_line(&mut line).expect("read response");
|
|
assert!(read > 0, "idx-mcp closed stdout");
|
|
let message: Value = serde_json::from_str(&line).expect("JSON-RPC line");
|
|
if message["id"] == id {
|
|
assert!(message.get("error").is_none(), "{method}: {message}");
|
|
return message["result"].clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tool_names(&mut self) -> Vec<String> {
|
|
let result = self.request("tools/list", json!({}));
|
|
let mut names: Vec<String> = result["tools"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|tool| tool["name"].as_str().unwrap().to_string())
|
|
.collect();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
/// Calls a tool, returning `(is_error, structured_content)`.
|
|
fn call(&mut self, name: &str, arguments: Value) -> (bool, Value) {
|
|
let result = self.request("tools/call", json!({"name": name, "arguments": arguments}));
|
|
let text = result["content"][0]["text"].as_str().unwrap();
|
|
let structured = result["structuredContent"].clone();
|
|
assert_eq!(serde_json::from_str::<Value>(text).unwrap(), structured);
|
|
(result["isError"] == true, structured)
|
|
}
|
|
|
|
fn ok(&mut self, name: &str, arguments: Value) -> Value {
|
|
let (is_error, body) = self.call(name, arguments);
|
|
assert!(!is_error, "{name} failed: {body}");
|
|
body
|
|
}
|
|
}
|
|
|
|
impl Drop for Client {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
/// The above-1% XLSX (2026-08-31) and two KSEI archive months (2026-01-30,
|
|
/// 2026-02-27), as in the CLI golden tests.
|
|
fn seed_ownership_db(db: &Path) {
|
|
let fixtures = core_dir().join("tests/fixtures");
|
|
let staging = db.parent().unwrap().join("staging");
|
|
std::fs::create_dir_all(&staging).unwrap();
|
|
// The importer derives the release from the IDX file name.
|
|
let xlsx = staging.join("peng-2026-08-00017-satu-persen.xlsx");
|
|
std::fs::copy(fixtures.join("ksei_above1_20260831_excerpt.xlsx"), &xlsx).unwrap();
|
|
|
|
let investor = Investor::new(&CoreConfig {
|
|
ownership_db: Some(db.to_path_buf()),
|
|
..CoreConfig::default()
|
|
});
|
|
for file in [
|
|
xlsx,
|
|
fixtures.join("ksei_balancepos_20260130_excerpt.txt"),
|
|
fixtures.join("ksei_balancepos_20260227_excerpt.txt"),
|
|
] {
|
|
investor
|
|
.import(&ImportSource::File(file), false)
|
|
.expect("seed import");
|
|
}
|
|
}
|
|
|
|
fn core_dir() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../idx-core")
|
|
.canonicalize()
|
|
.expect("idx-core crate dir")
|
|
}
|
|
|
|
fn assert_fetched(value: &Value) {
|
|
assert!(value.get("data").is_some(), "{value}");
|
|
assert_eq!(value["stale"], false, "{value}");
|
|
assert!(value["source"].is_string(), "{value}");
|
|
assert!(value["fetched_at"].is_string(), "{value}");
|
|
}
|
|
|
|
#[test]
|
|
fn lists_grouped_tools_and_hides_sync_unless_allowed() {
|
|
let mut client = Client::start(&[], false);
|
|
assert_eq!(client.tool_names(), TOOLS);
|
|
|
|
let mut client = Client::start(&["--allow-sync"], false);
|
|
let mut expected: Vec<&str> = TOOLS.to_vec();
|
|
expected.push("ownership_sync");
|
|
expected.sort();
|
|
assert_eq!(client.tool_names(), expected);
|
|
}
|
|
|
|
/// What agents see (names, descriptions, input schemas) is part of the
|
|
/// contract: changes must show up as a reviewed diff. Regenerate with
|
|
/// `UPDATE_GOLDEN=1`.
|
|
#[test]
|
|
fn tool_definitions_match_golden() {
|
|
let mut client = Client::start(&["--allow-sync"], false);
|
|
let mut tools = client.request("tools/list", json!({}))["tools"]
|
|
.as_array()
|
|
.unwrap()
|
|
.clone();
|
|
tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
|
let rendered = serde_json::to_string_pretty(&tools).unwrap() + "\n";
|
|
|
|
let golden = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/tools.json");
|
|
if std::env::var_os("UPDATE_GOLDEN").is_some() {
|
|
std::fs::create_dir_all(golden.parent().unwrap()).unwrap();
|
|
std::fs::write(&golden, &rendered).unwrap();
|
|
}
|
|
let expected = std::fs::read_to_string(&golden).expect("tests/golden/tools.json");
|
|
assert!(
|
|
rendered == expected,
|
|
"tool definitions changed; review and rerun with UPDATE_GOLDEN=1:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn live_tools_wrap_data_with_freshness() {
|
|
let mut client = Client::start(&[], false);
|
|
|
|
let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]}));
|
|
assert_fetched("es["quotes"][0]);
|
|
assert_eq!(quotes["quotes"][0]["data"]["symbol"], "BBCA.JK");
|
|
|
|
let history = client.ok("get_price_history", json!({"symbol": "BBCA"}));
|
|
assert_fetched(&history);
|
|
assert!(!history["data"].as_array().unwrap().is_empty());
|
|
|
|
let screen = client.ok("screen_stocks", json!({"limit": 1}));
|
|
assert_fetched(&screen);
|
|
assert_eq!(screen["data"].as_array().unwrap().len(), 1);
|
|
|
|
let company = client.ok("get_company", json!({"symbol": "BBCA"}));
|
|
assert_eq!(company["symbol"], "BBCA");
|
|
for section in [
|
|
"profile",
|
|
"financials",
|
|
"earnings",
|
|
"sentiment",
|
|
"insights",
|
|
"news",
|
|
] {
|
|
assert_fetched(&company[section]);
|
|
}
|
|
assert!(company.get("errors").is_none(), "{company}");
|
|
|
|
let only_profile = client.ok(
|
|
"get_company",
|
|
json!({"symbol": "BBCA", "sections": ["profile"]}),
|
|
);
|
|
assert!(only_profile.get("financials").is_none());
|
|
|
|
let analysis = client.ok("analyze_stock", json!({"symbol": "BBCA"}));
|
|
for kind in ["technical", "growth", "valuation", "risk"] {
|
|
assert_fetched(&analysis[kind]);
|
|
}
|
|
|
|
let brokers = client.ok(
|
|
"get_broker_summary",
|
|
json!({"symbol": "BBCA", "date": "2026-10-16"}),
|
|
);
|
|
assert_fetched(&brokers);
|
|
assert_eq!(brokers["source"], "ipot");
|
|
assert_eq!(brokers["data"]["buyers"][0]["code"], "MG");
|
|
assert_eq!(brokers["data"]["sellers"][0]["name"], "MANDIRI SEKURITAS");
|
|
assert_eq!(brokers["data"]["foreign_net_value"], -2_300_000_000_000_i64);
|
|
|
|
let bandar = client.ok(
|
|
"analyze_bandar",
|
|
json!({"symbols": ["BBCA", "BBRI", "ZZZZ"], "date": "2026-10-16", "top": 3}),
|
|
);
|
|
let reports = bandar["reports"].as_array().unwrap();
|
|
assert_eq!(reports.len(), 2);
|
|
assert_fetched(&reports[0]);
|
|
assert_eq!(reports[0]["data"]["top"], 3);
|
|
assert_eq!(reports[0]["data"]["method"], "top_concentration_v1");
|
|
assert!(
|
|
reports[0]["data"]["imbalance_bps"].as_i64()
|
|
>= reports[1]["data"]["imbalance_bps"].as_i64()
|
|
);
|
|
assert_eq!(bandar["failures"][0]["symbol"], "ZZZZ.JK");
|
|
assert_eq!(bandar["failures"][0]["code"], "SYMBOLNOTFOUND");
|
|
|
|
let flow = client.ok(
|
|
"get_foreign_flow",
|
|
json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16"}),
|
|
);
|
|
assert_fetched(&flow);
|
|
assert_eq!(flow["source"], "ipot");
|
|
assert_eq!(flow["data"]["days"].as_array().unwrap().len(), 5);
|
|
assert_eq!(
|
|
flow["data"]["days"][4]["foreign_sell_value"],
|
|
4_000_000_000_000_i64
|
|
);
|
|
let idx_flow = client.ok(
|
|
"get_foreign_flow",
|
|
json!({"symbol": "BBCA", "from": "2026-10-12", "to": "2026-10-16", "source": "idx"}),
|
|
);
|
|
assert_eq!(idx_flow["source"], "idx");
|
|
assert_eq!(idx_flow["data"]["days"][4]["close"], 5175);
|
|
|
|
let compared = client.ok("compare_stocks", json!({"symbols": ["BBCA", "BBRI"]}));
|
|
assert_eq!(compared["reports"].as_array().unwrap().len(), 2);
|
|
assert_eq!(compared["failures"], json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn intraday_is_stockbit_whichever_provider_and_empty_sessions_are_data() {
|
|
let mut client = Client::start(&[], false);
|
|
|
|
let day = client.ok("get_intraday", json!({"symbol": "BBCA"}));
|
|
assert_fetched(&day);
|
|
assert_eq!(day["source"], "stockbit");
|
|
let quote = &day["data"]["quote"];
|
|
assert_eq!(quote["symbol"], "BBCA.JK");
|
|
assert_eq!(quote["market_status"], "close");
|
|
// The closing book is not reported as a live one.
|
|
assert!(
|
|
quote.get("bid").is_none() && quote.get("offer").is_none(),
|
|
"{quote}"
|
|
);
|
|
assert_eq!(day["data"]["points"].as_array().unwrap().len(), 335);
|
|
|
|
let tail = client.ok("get_intraday", json!({"symbol": "BBCA", "tail": 2}));
|
|
let points = tail["data"]["points"].as_array().unwrap();
|
|
assert_eq!(points.len(), 2);
|
|
assert_eq!(points[1]["time"], "2026-10-22T16:14:00+07:00");
|
|
|
|
let suspended = client.ok("get_intraday", json!({"symbol": "MABA"}));
|
|
assert_eq!(suspended["data"]["points"], json!([]));
|
|
assert_eq!(
|
|
suspended["data"]["quote"]["session_note"],
|
|
"Stockbit lists the stock as suspended"
|
|
);
|
|
|
|
for (arguments, code) in [
|
|
(json!({"symbol": "ZZZZ"}), "SYMBOLNOTFOUND"),
|
|
(json!({"symbol": "BBCA", "tail": 0}), "INVALIDINPUT"),
|
|
(json!({"symbol": "BBCA", "tail": 401}), "INVALIDINPUT"),
|
|
] {
|
|
let (is_error, body) = client.call("get_intraday", arguments.clone());
|
|
assert!(is_error, "{arguments}: {body}");
|
|
assert_eq!(body["code"], code, "{arguments}: {body}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
|
|
let mut client = Client::start_with_env(&[], false, &[("IDX_PROVIDER", "stockbit")]);
|
|
let quotes = client.ok("get_quotes", json!({"symbols": ["BBCA"]}));
|
|
assert_eq!(quotes["quotes"][0]["source"], "stockbit");
|
|
assert_eq!(quotes["quotes"][0]["data"]["market_status"], "close");
|
|
|
|
let mut blocked = Client::start_with_env(&[], false, &[("IDX_MOCK_STOCKBIT_STATUS", "403")]);
|
|
let (is_error, body) = blocked.call("get_intraday", json!({"symbol": "BBCA"}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "UPSTREAMBLOCKED");
|
|
assert!(
|
|
body["message"].as_str().unwrap().contains("does not retry"),
|
|
"{body}"
|
|
);
|
|
}
|
|
|
|
/// 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
|
|
/// reports the sign-in finishing.
|
|
#[test]
|
|
fn stockbit_login_returns_a_page_that_takes_the_cookie() {
|
|
let mut client = Client::start(&[], false);
|
|
let login = client.ok("stockbit_login", json!({}));
|
|
assert_eq!(login["method"], "page", "{login}");
|
|
assert_eq!(login["state"], "waiting", "{login}");
|
|
assert!(login["reason"].is_string(), "auto says why: {login}");
|
|
let url = login["url"].as_str().expect("url").to_string();
|
|
assert!(url.starts_with("http://127.0.0.1:"), "{login}");
|
|
assert!(
|
|
login["forward"].as_str().unwrap().starts_with("ssh -L "),
|
|
"{login}"
|
|
);
|
|
assert_eq!(login["already_open"], false, "{login}");
|
|
assert!(login["next"].as_str().unwrap().contains("stockbit_status"));
|
|
|
|
// A second call hands back the page that is already up.
|
|
let again = client.ok("stockbit_login", json!({}));
|
|
assert_eq!(again["url"], login["url"]);
|
|
assert_eq!(again["already_open"], true, "{again}");
|
|
|
|
let waiting = client.ok("stockbit_status", json!({}));
|
|
assert_eq!(waiting["logged_in"], false, "{waiting}");
|
|
assert_eq!(waiting["sign_in"]["state"], "waiting", "{waiting}");
|
|
|
|
let cookie = std::fs::read_to_string(
|
|
core_dir().join("tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt"),
|
|
)
|
|
.expect("cookie fixture");
|
|
let (status, body) = post(&url, cookie.trim());
|
|
assert_eq!(status, 200, "{body}");
|
|
assert!(body.contains("\"ok\":true"), "{body}");
|
|
|
|
// The sign-in's thread records the outcome just after the page answers.
|
|
let mut status = client.ok("stockbit_status", json!({}));
|
|
for _ in 0..50 {
|
|
if status["sign_in"]["state"] != "waiting" {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
status = client.ok("stockbit_status", json!({}));
|
|
}
|
|
assert_eq!(status["logged_in"], true, "{status}");
|
|
assert_eq!(status["sign_in"]["state"], "signed_in", "{status}");
|
|
|
|
// Now there is nothing to sign in to.
|
|
let done = client.ok("stockbit_login", json!({}));
|
|
assert_eq!(done["state"], "signed_in", "{done}");
|
|
assert_eq!(done["method"], "existing", "{done}");
|
|
}
|
|
|
|
/// Asking for the browser where there is none fails, instead of quietly
|
|
/// serving the page.
|
|
#[test]
|
|
fn stockbit_login_with_the_browser_forced_fails_without_one() {
|
|
let mut client = Client::start(&[], false);
|
|
let (is_error, body) = client.call("stockbit_login", json!({"method": "browser"}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "UNSUPPORTED", "{body}");
|
|
let status = client.ok("stockbit_status", json!({}));
|
|
assert!(status.get("sign_in").is_none(), "nothing started: {status}");
|
|
}
|
|
|
|
/// POSTs to the sign-in page the way its own script does.
|
|
fn post(url: &str, body: &str) -> (u16, String) {
|
|
use std::io::Read;
|
|
use std::net::TcpStream;
|
|
|
|
let rest = url.trim_start_matches("http://");
|
|
let (authority, path) = rest.split_once('/').expect("path");
|
|
let mut stream = TcpStream::connect(authority).expect("connect to the page");
|
|
stream
|
|
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
|
.unwrap();
|
|
write!(
|
|
stream,
|
|
"POST /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Type: text/plain\r\n\
|
|
Content-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
)
|
|
.unwrap();
|
|
let mut raw = String::new();
|
|
stream.read_to_string(&mut raw).unwrap();
|
|
let status = raw
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.and_then(|code| code.parse().ok())
|
|
.unwrap_or(0);
|
|
(
|
|
status,
|
|
raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn the_order_book_needs_a_session_and_status_never_shows_tokens() {
|
|
let mut client = Client::start(&[], false);
|
|
let status = client.ok("stockbit_status", json!({}));
|
|
assert_eq!(status["logged_in"], false);
|
|
let (is_error, body) = client.call("get_orderbook", json!({"symbol": "BBCA"}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "AUTHERROR");
|
|
|
|
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
|
let status = client.ok("stockbit_status", json!({}));
|
|
assert_eq!(status["logged_in"], true);
|
|
assert!(!status.to_string().contains(TEST_SESSION.1));
|
|
|
|
let book = client.ok("get_orderbook", json!({"symbol": "BBCA", "depth": 2}));
|
|
assert_fetched(&book);
|
|
assert_eq!(book["source"], "stockbit");
|
|
let offers = book["data"]["offers"].as_array().unwrap();
|
|
assert_eq!(offers.len(), 2);
|
|
assert_eq!(
|
|
offers[0],
|
|
json!({"price": 5175, "shares": 264_462_500, "orders": 3284})
|
|
);
|
|
assert_eq!(book["data"]["foreign_net"], -2_329_493_087_500_i64);
|
|
assert_eq!(book["data"]["market_status"], "STATE_NAME_MARKET_CLOSED");
|
|
for depth in [0, 51] {
|
|
let (is_error, body) =
|
|
client.call("get_orderbook", json!({"symbol": "BBCA", "depth": depth}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn corporate_actions_need_a_session_and_keep_kinds_apart() {
|
|
let mut client = Client::start(&[], false);
|
|
let (is_error, body) = client.call("get_corporate_actions", json!({"symbol": "BBCA"}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "AUTHERROR");
|
|
|
|
let mut client = Client::start_with_env(
|
|
&[],
|
|
false,
|
|
&[TEST_SESSION, ("IDX_MOCK_TODAY", "2026-11-03")],
|
|
);
|
|
let actions = client.ok(
|
|
"get_corporate_actions",
|
|
json!({"symbol": "BBCA", "kinds": ["dividend", "split"]}),
|
|
);
|
|
assert_fetched(&actions);
|
|
assert_eq!(actions["source"], "stockbit");
|
|
let rows = actions["data"].as_array().unwrap();
|
|
assert_eq!(rows.len(), 40, "37 dividends and 3 splits");
|
|
assert_eq!(rows[0]["kind"], "dividend");
|
|
assert_eq!(rows[0]["date"], "2026-08-28");
|
|
assert_eq!(rows[0]["cash_per_share"], 25.0);
|
|
assert!(rows[0].get("ratio").is_none(), "absent fields are omitted");
|
|
let split = rows.iter().find(|r| r["kind"] == "split").unwrap();
|
|
assert_eq!(split["ratio"], json!({"old": 1, "new": 5}));
|
|
|
|
let calendar = client.ok(
|
|
"get_corporate_calendar",
|
|
json!({"to": "2026-11-12", "kinds": ["rights", "dividend"]}),
|
|
);
|
|
let rows = calendar["data"].as_array().unwrap();
|
|
assert_eq!(rows[0]["symbol"], "MGLV.JK");
|
|
assert_eq!(rows[0]["price"], 8880);
|
|
assert!(
|
|
rows.windows(2)
|
|
.all(|w| w[0]["date"].as_str() <= w[1]["date"].as_str())
|
|
);
|
|
|
|
let (is_error, body) = client.call(
|
|
"get_corporate_actions",
|
|
json!({"symbol": "BBCA", "kinds": ["rups"]}),
|
|
);
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT");
|
|
}
|
|
|
|
#[test]
|
|
fn shareholder_movements_page_and_filter() {
|
|
let mut client = Client::start(&[], false);
|
|
let (is_error, body) = client.call("get_shareholder_movements", json!({}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "AUTHERROR");
|
|
|
|
let mut client = Client::start_with_env(&[], false, &[TEST_SESSION]);
|
|
let market = client.ok("get_shareholder_movements", json!({"pages": 2}));
|
|
assert_fetched(&market);
|
|
assert_eq!(market["source"], "stockbit");
|
|
let rows = market["data"]["movements"].as_array().unwrap();
|
|
assert_eq!(rows.len(), 14, "8 rows on page 1, 6 on the last page");
|
|
assert_eq!(market["data"]["more"], false);
|
|
assert!(
|
|
rows.windows(2)
|
|
.all(|w| w[0]["date"].as_str() >= w[1]["date"].as_str()),
|
|
"newest first"
|
|
);
|
|
|
|
let sells = client.ok(
|
|
"get_shareholder_movements",
|
|
json!({"action": "sell", "source": "ksei", "holder_id": "1000004481", "pages": 3}),
|
|
);
|
|
let rows = sells["data"]["movements"].as_array().unwrap();
|
|
assert_eq!(rows.len(), 2);
|
|
assert!(rows.iter().all(|r| r["symbol"] == "ATLA"
|
|
&& r["action"] == "sell"
|
|
&& r["shares_change"].as_i64() < Some(0)
|
|
&& r["price_estimated"] == true));
|
|
|
|
let bbca = client.ok("get_shareholder_movements", json!({"symbol": "bbca"}));
|
|
assert_eq!(bbca["data"]["more"], true, "BBCA has a second page");
|
|
assert_eq!(bbca["data"]["movements"][0]["roles"], json!(["director"]));
|
|
|
|
for bad in [
|
|
json!({"action": "dividend"}),
|
|
json!({"source": "bing"}),
|
|
json!({"holder_id": "RUDI"}),
|
|
json!({"pages": 11}),
|
|
json!({"from": "2026-11-02", "to": "2026-11-01"}),
|
|
] {
|
|
let (is_error, body) = client.call("get_shareholder_movements", bad.clone());
|
|
assert!(is_error, "{bad} -> {body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT", "{bad} -> {body}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn errors_carry_cli_codes() {
|
|
let mut client = Client::start(&[], false);
|
|
|
|
let (is_error, body) = client.call(
|
|
"get_price_history",
|
|
json!({"symbol": "BBCA", "period": "7y"}),
|
|
);
|
|
assert!(is_error);
|
|
assert_eq!(body["code"], "INVALIDINPUT");
|
|
assert_eq!(body["error"], true);
|
|
|
|
let (is_error, body) = client.call("screen_stocks", json!({"filter": "cheap"}));
|
|
assert!(is_error);
|
|
assert_eq!(body["code"], "INVALIDINPUT");
|
|
|
|
for (tool, arguments) in [
|
|
("get_quotes", json!({"symbols": []})),
|
|
("compare_stocks", json!({"symbols": []})),
|
|
("get_company", json!({"symbol": "BBCA", "sections": []})),
|
|
("analyze_stock", json!({"symbol": "BBCA", "kinds": []})),
|
|
(
|
|
"get_broker_summary",
|
|
json!({"symbol": "BBCA", "date": "2026-10-16", "from": "2026-10-12"}),
|
|
),
|
|
(
|
|
"get_broker_summary",
|
|
json!({"symbol": "BBCA", "date": "2026-10-16", "board": "dark"}),
|
|
),
|
|
("analyze_bandar", json!({"symbols": ["BBCA"], "top": 4})),
|
|
("analyze_bandar", json!({"symbols": []})),
|
|
("get_foreign_flow", json!({"symbol": "BBCA", "days": 61})),
|
|
(
|
|
"get_foreign_flow",
|
|
json!({"symbol": "BBCA", "source": "rti"}),
|
|
),
|
|
(
|
|
"get_foreign_flow",
|
|
json!({"symbol": "BBCA", "days": 3, "from": "2026-10-12"}),
|
|
),
|
|
] {
|
|
let (is_error, body) = client.call(tool, arguments);
|
|
assert!(is_error, "{tool}: {body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT", "{tool}");
|
|
}
|
|
|
|
let many: Vec<String> = (0..21).map(|i| format!("S{i}")).collect();
|
|
for (tool, arguments) in [
|
|
("get_quotes", json!({"symbols": many})),
|
|
("screen_stocks", json!({"limit": 201})),
|
|
("screen_stocks", json!({"limit": 0})),
|
|
("get_quotes", json!({"symbols": ["../../REPORT"]})),
|
|
] {
|
|
let (is_error, body) = client.call(tool, arguments.clone());
|
|
assert!(is_error, "{tool} {arguments}: {body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT", "{tool} {arguments}");
|
|
}
|
|
// Duplicates collapse instead of counting against the cap.
|
|
let repeated = vec!["BBCA"; 50];
|
|
let quotes = client.ok("get_quotes", json!({"symbols": repeated}));
|
|
assert_eq!(quotes["quotes"].as_array().unwrap().len(), 1);
|
|
|
|
// Empty ownership database: investor tools point at the sync path.
|
|
let status = client.ok("ownership_status", json!({}));
|
|
assert_eq!(status["populated"], false);
|
|
let (is_error, body) = client.call("get_shareholders", json!({"ticker": "BBCA"}));
|
|
assert!(is_error);
|
|
assert_eq!(body["code"], "NOMARKETDATA");
|
|
assert!(body["message"].as_str().unwrap().contains("--allow-sync"));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_tools_read_the_local_database() {
|
|
let mut client = Client::start(&[], true);
|
|
|
|
let status = client.ok("ownership_status", json!({}));
|
|
assert_eq!(status["populated"], true);
|
|
assert_eq!(status["latest_as_of_date"], "2026-08-31");
|
|
assert_eq!(status["releases"].as_array().unwrap().len(), 3);
|
|
|
|
let holders = client.ok("get_shareholders", json!({"ticker": "bbca"}));
|
|
assert_eq!(holders["ticker"]["code"], "BBCA");
|
|
assert!(!holders["holders"].as_array().unwrap().is_empty());
|
|
|
|
let (is_error, body) = client.call("get_shareholders", json!({"ticker": "ZZZZ"}));
|
|
assert!(is_error, "{body}");
|
|
|
|
let holdings = client.ok("get_investor_holdings", json!({"entity": "DWIMURIA"}));
|
|
assert!(
|
|
!holdings["holdings"].as_array().unwrap().is_empty(),
|
|
"{holdings}"
|
|
);
|
|
|
|
let found = client.ok("search_investors", json!({"query": "DWIMURIA"}));
|
|
assert!(!found["entities"].as_array().unwrap().is_empty(), "{found}");
|
|
|
|
let all = client.ok(
|
|
"get_ownership_changes",
|
|
json!({"from": "2026-01-30", "to": "2026-02-27"}),
|
|
);
|
|
let total = all["total"].as_u64().unwrap();
|
|
assert!(total > 1, "{all}");
|
|
let ticker = all["changes"][0]["ticker_code"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
let limited = client.ok(
|
|
"get_ownership_changes",
|
|
json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 1}),
|
|
);
|
|
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());
|
|
// The summary covers all rows, so every page carries the same one.
|
|
assert_eq!(page["summary"], all["summary"], "offset {next}");
|
|
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::<u64>(),
|
|
total
|
|
);
|
|
assert!(all["summary"]["tickers"].as_u64().unwrap() >= 1, "{all}");
|
|
|
|
// Past the end: an empty last page, not an error.
|
|
for past in [total, total + 5] {
|
|
let page = client.ok(
|
|
"get_ownership_changes",
|
|
json!({"from": "2026-01-30", "to": "2026-02-27", "offset": past}),
|
|
);
|
|
assert_eq!(page["total"], total, "offset {past}");
|
|
assert_eq!(
|
|
page["changes"].as_array().unwrap().len(),
|
|
0,
|
|
"offset {past}"
|
|
);
|
|
assert!(page["next_offset"].is_null(), "offset {past}: {page}");
|
|
}
|
|
|
|
// Bad, unknown, or reversed dates are errors, not "everyone is new".
|
|
for (from, to) in [
|
|
("", "2026-02-27"),
|
|
("1999-01-01", "2026-02-27"),
|
|
("2026-02-27", "2026-01-30"),
|
|
("2026-01-30", "2026-01-30"),
|
|
] {
|
|
let (is_error, body) =
|
|
client.call("get_ownership_changes", json!({"from": from, "to": to}));
|
|
assert!(is_error, "{from}..{to}: {body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT", "{from}..{to}: {body}");
|
|
}
|
|
let (is_error, body) = client.call("get_investor_holdings", json!({"entity": " "}));
|
|
assert!(is_error, "{body}");
|
|
assert_eq!(body["code"], "INVALIDINPUT", "{body}");
|
|
|
|
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",
|
|
json!({"from": "2026-01-30", "to": "2026-02-27", "ticker": ticker.to_lowercase()}),
|
|
);
|
|
let rows = one["changes"].as_array().unwrap();
|
|
assert!(!rows.is_empty());
|
|
assert!(rows.iter().all(|row| row["ticker_code"] == ticker.as_str()));
|
|
}
|
|
|
|
/// A temporary directory removed on drop (no extra dependency).
|
|
mod tempdir {
|
|
use super::*;
|
|
|
|
pub struct Dir(PathBuf);
|
|
|
|
impl Dir {
|
|
pub fn new() -> Self {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"idx-mcp-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
let _ = std::fs::remove_dir_all(&path);
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for Dir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
}
|