mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
`stockbit::signin` is now the single sign-in flow. `idx stockbit login` waits on it in the foreground; the `stockbit_login` MCP tool runs it on a thread and `stockbit_status` reports `sign_in.state`, so an agent knows when to stop polling. Auto uses a browser unless the shell is over SSH or has no display, and falls back to the local page only when the browser cannot start. The browser way opens Stockbit's sign-in page in a profile only idx uses and reads the session cookie over DevTools. What a real sign-in showed: - The cookie is host-only on stockbit.com, so deleting by the assumed .stockbit.com removed nothing and left live tokens in the profile. It is now deleted by its own domain and path, with every other tab closed first, and read back to confirm. - A signed-out page writes the cookie without tokens; it no longer counts. - A profile belongs to one browser, so it records the executable that made it. - On macOS the config directory depends on XDG_CONFIG_HOME, so a shell and an app started from the Dock disagreed; both default locations are now checked. - A thread does not outlive main, so idx-mcp closes a waiting browser when stdin closes or on SIGTERM or Ctrl-C. Progress goes to stderr, which keeps `-o json` to one document on stdout. Google refused its own sign-in in this window on 2026-10-31, most likely over the debugging flag; password sign-in works, and the docs and roadmap say so. Claude-Session: https://claude.ai/code/session_01RqPPVaoFxLToGAXXBEoCtH
729 lines
26 KiB
Rust
729 lines
26 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; 18] = [
|
|
"analyze_bandar",
|
|
"analyze_stock",
|
|
"compare_stocks",
|
|
"get_broker_summary",
|
|
"get_company",
|
|
"get_foreign_flow",
|
|
"get_intraday",
|
|
"get_investor_holdings",
|
|
"get_orderbook",
|
|
"get_ownership_changes",
|
|
"get_price_history",
|
|
"get_quotes",
|
|
"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}"
|
|
);
|
|
}
|
|
|
|
/// 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 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);
|
|
}
|
|
}
|
|
}
|