feat(mcp): add idx-mcp, a stdio MCP server over idx-core

Phase 4 of docs/DESIGN.md. Eleven grouped tools (quotes, history, screen,
company, analysis, compare, shareholders, investor holdings, search,
ownership changes, status); ownership_sync is listed only with
--allow-sync. Live results keep the Fetched envelope, errors use the CLI's
{error, code, message} body, and investor tools on an empty database
point at the sync path.

idx-core gains Period/Interval FromStr, Investor::status, a ticker
filter on changes, and Display for ErrorCode (now used by the CLI too).
CLI output and goldens are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 18:19:04 +00:00
commit cda12fe2db
18 changed files with 1546 additions and 38 deletions

31
crates/idx-mcp/Cargo.toml Normal file
View file

@ -0,0 +1,31 @@
[package]
name = "idx-mcp"
description = "MCP server for Indonesian stock market (IDX) market, financial, and ownership data"
version.workspace = true
edition.workspace = true
# rmcp requires 1.88.
rust-version = "1.88"
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "../../README.md"
keywords = ["idx", "stocks", "indonesia", "mcp", "finance"]
categories = ["finance"]
include = ["/Cargo.toml", "/LICENSE", "/src/**"]
[[bin]]
name = "idx-mcp"
path = "src/main.rs"
[dependencies]
idx-core.workspace = true
clap.workspace = true
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
schemars = "1"
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
[dev-dependencies]
# Tests drive the binary with the mock provider; release builds exclude it.
idx-core = { workspace = true, features = ["testing"] }

1
crates/idx-mcp/LICENSE Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1,51 @@
//! `idx-mcp`: an MCP server (stdio) over idx-core. See docs/DESIGN.md.
mod params;
mod server;
use std::process::ExitCode;
use clap::Parser;
use idx_core::config::CoreConfig;
use rmcp::ServiceExt;
#[derive(Debug, Parser)]
#[command(
name = "idx-mcp",
version,
about = "MCP server (stdio) for Indonesian stock market (IDX) data"
)]
struct Args {
/// Expose the ownership_sync tool, which downloads the KSEI ownership
/// snapshot (several MB) into the local database.
#[arg(long)]
allow_sync: bool,
}
#[tokio::main]
async fn main() -> ExitCode {
let args = Args::parse();
let config = match CoreConfig::load() {
Ok(config) => config,
Err(err) => {
eprintln!("error: {err}");
return ExitCode::from(err.exit_code() as u8);
}
};
let server = server::IdxServer::new(config, args.allow_sync);
let running = match server.serve(rmcp::transport::stdio()).await {
Ok(running) => running,
Err(err) => {
eprintln!("error: {err}");
return ExitCode::FAILURE;
}
};
match running.waiting().await {
Ok(_) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}

View file

@ -0,0 +1,177 @@
//! Tool inputs. String enums are validated by idx-core's `FromStr`, so the
//! accepted values and error messages match the CLI; the schemas list them.
use idx_core::api::types::{Interval, Period};
use idx_core::market::{ScreenFilter, ScreenRegion};
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SymbolsParams {
/// IDX tickers, e.g. ["BBCA", "BBRI"]; the `.JK` suffix is optional.
pub symbols: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct HistoryParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// Lookback window (default "3mo").
#[serde(default)]
#[schemars(schema_with = "period_schema")]
pub period: Option<String>,
/// Candle size (default "1d").
#[serde(default)]
#[schemars(schema_with = "interval_schema")]
pub interval: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ScreenParams {
/// Screen to run (default "top-performers").
#[serde(default)]
#[schemars(schema_with = "filter_schema")]
pub filter: Option<String>,
/// Market region (default "id").
#[serde(default)]
#[schemars(schema_with = "region_schema")]
pub region: Option<String>,
/// Maximum results (default 50).
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum CompanySection {
Profile,
Financials,
Earnings,
Sentiment,
Insights,
News,
}
impl CompanySection {
pub const ALL: [Self; 6] = [
Self::Profile,
Self::Financials,
Self::Earnings,
Self::Sentiment,
Self::Insights,
Self::News,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Profile => "profile",
Self::Financials => "financials",
Self::Earnings => "earnings",
Self::Sentiment => "sentiment",
Self::Insights => "insights",
Self::News => "news",
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CompanyParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// Sections to fetch (default: all).
#[serde(default)]
pub sections: Option<Vec<CompanySection>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AnalysisKind {
Technical,
Growth,
Valuation,
Risk,
}
impl AnalysisKind {
pub const ALL: [Self; 4] = [Self::Technical, Self::Growth, Self::Valuation, Self::Risk];
pub fn as_str(self) -> &'static str {
match self {
Self::Technical => "technical",
Self::Growth => "growth",
Self::Valuation => "valuation",
Self::Risk => "risk",
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AnalyzeParams {
/// IDX ticker, e.g. "BBCA".
pub symbol: String,
/// Analyses to run (default: all).
#[serde(default)]
pub kinds: Option<Vec<AnalysisKind>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ShareholdersParams {
/// IDX ticker, e.g. "BBCA".
pub ticker: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct EntityParams {
/// Investor name or part of it, e.g. "DWIMURIA"; the closest match is used.
pub entity: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SearchParams {
/// Full-text query over investor names.
pub query: String,
/// Maximum results (default 20).
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ChangesParams {
/// Earlier release date, YYYY-MM-DD (see ownership_status).
pub from: String,
/// Later release date, YYYY-MM-DD.
pub to: String,
/// Only this ticker's changes.
#[serde(default)]
pub ticker: Option<String>,
/// Maximum rows returned (default 200); `total` reports the full count.
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SyncParams {
/// Re-download even when the local snapshot is current.
#[serde(default)]
pub force: bool,
}
fn string_enum(values: Vec<&'static str>) -> Schema {
json_schema!({ "type": "string", "enum": values })
}
fn period_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(Period::ALL.iter().map(Period::as_str).collect())
}
fn interval_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(Interval::ALL.iter().map(Interval::as_str).collect())
}
fn filter_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(ScreenFilter::ALL.map(ScreenFilter::as_str).to_vec())
}
fn region_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(ScreenRegion::ALL.map(ScreenRegion::as_str).to_vec())
}

View file

@ -0,0 +1,391 @@
//! MCP tools over idx-core. Each call runs on a blocking thread with a fresh
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
//! cache and ownership database.
use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::config::CoreConfig;
use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter};
use idx_core::investor::{HolderSource, Investor};
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
use idx_core::{CachePolicy, Fetched, Idx};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConfig};
use rmcp::{ErrorData, ServerHandler, tool, tool_handler, tool_router};
use serde::Serialize;
use serde_json::{Map, Value, json};
use crate::params::{
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams,
HistoryParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
};
const SYNC_TOOL: &str = "ownership_sync";
const NEWS_LIMIT: usize = 10;
const SCREEN_LIMIT: usize = 50;
const SEARCH_LIMIT: usize = 20;
const CHANGES_LIMIT: usize = 200;
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 \
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.";
type ToolResult = Result<CallToolResult, ErrorData>;
#[derive(Clone)]
pub struct IdxServer {
config: CoreConfig,
allow_sync: bool,
tool_router: ToolRouter<Self>,
}
impl IdxServer {
pub fn new(config: CoreConfig, allow_sync: bool) -> Self {
let mut tool_router = Self::tool_router();
if !allow_sync {
tool_router.remove_route(SYNC_TOOL);
}
Self {
config,
allow_sync,
tool_router,
}
}
/// Runs `f` against live data on a blocking thread.
async fn live<T, F>(&self, f: F) -> ToolResult
where
T: Serialize + Send + 'static,
F: FnOnce(&Idx) -> Result<T, IdxError> + Send + 'static,
{
let config = self.config.clone();
blocking(move || f(&Idx::new(config)?)).await
}
/// Runs `f` against the ownership database, failing with a hint to sync
/// when it is empty.
async fn local<T, F>(&self, f: F) -> ToolResult
where
T: Serialize + Send + 'static,
F: FnOnce(&Investor) -> Result<T, IdxError> + Send + 'static,
{
let investor = Investor::new(&self.config);
let hint = if self.allow_sync {
"call ownership_sync first"
} else {
"run `idx ownership sync`, or restart idx-mcp with --allow-sync"
};
blocking(move || {
if !investor.status()?.populated {
return Err(IdxError::NoMarketData(format!(
"ownership database is empty; {hint}"
)));
}
f(&investor)
})
.await
}
}
#[tool_router]
impl IdxServer {
#[tool(
description = "Latest quotes (price, change, volume, 52-week range, market cap) \
for one or more IDX tickers."
)]
async fn get_quotes(&self, params: Parameters<SymbolsParams>) -> ToolResult {
let SymbolsParams { symbols } = params.0;
self.live(move |idx| {
let quotes = idx.market().quotes(&symbols, CachePolicy::Default)?;
Ok(json!({ "quotes": quotes }))
})
.await
}
#[tool(description = "OHLCV price history for one IDX ticker.")]
async fn get_price_history(&self, params: Parameters<HistoryParams>) -> ToolResult {
let HistoryParams {
symbol,
period,
interval,
} = params.0;
self.live(move |idx| {
let period = period.as_deref().unwrap_or("3mo").parse()?;
let interval = interval.as_deref().unwrap_or("1d").parse()?;
let source = idx.market().history_source(None);
idx.market()
.history(&symbol, &period, &interval, source, CachePolicy::Default)
})
.await
}
#[tool(
description = "Run a stock screen (top/worst performers, high dividend, low P/E, \
52-week highs/lows, high volume, large cap)."
)]
async fn screen_stocks(&self, params: Parameters<ScreenParams>) -> ToolResult {
let ScreenParams {
filter,
region,
limit,
} = params.0;
self.live(move |idx| {
let query = ScreenQuery {
filter: filter
.as_deref()
.unwrap_or("top-performers")
.parse::<ScreenFilter>()?,
region: region.as_deref().unwrap_or("id").parse::<ScreenRegion>()?,
limit: limit.unwrap_or(SCREEN_LIMIT),
};
idx.market().screen(&query, CachePolicy::Default)
})
.await
}
#[tool(
description = "Company data for one IDX ticker: profile, financial statements, \
earnings, analyst sentiment, insights, and news. Sections that fail are listed under \
`errors`; the call fails only if every section fails."
)]
async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult {
let CompanyParams { symbol, sections } = params.0;
let sections = sections.unwrap_or_else(|| CompanySection::ALL.to_vec());
self.live(move |idx| {
let financial = idx.financial();
let policy = CachePolicy::Default;
let mut report = SectionReport::new(&symbol);
for section in sections {
let name = section.as_str();
match section {
CompanySection::Profile => report.add(name, financial.profile(&symbol, policy)),
CompanySection::Financials => report.add(
name,
financial.statements(&symbol, &StatementFilter::all(), policy),
),
CompanySection::Earnings => report.add(
name,
financial.earnings(&symbol, EarningsFilter::default(), policy),
),
CompanySection::Sentiment => {
report.add(name, financial.sentiment(&symbol, policy))
}
CompanySection::Insights => {
report.add(name, financial.insights(&symbol, policy))
}
CompanySection::News => {
report.add(name, financial.news(&symbol, NEWS_LIMIT, policy))
}
}
}
report.finish()
})
.await
}
#[tool(
description = "Analyze one IDX ticker: technical signals (trend, RSI, MACD, \
moving averages) and fundamental growth, valuation, and risk ratings. Analyses that fail \
are listed under `errors`; the call fails only if every analysis fails."
)]
async fn analyze_stock(&self, params: Parameters<AnalyzeParams>) -> ToolResult {
let AnalyzeParams { symbol, kinds } = params.0;
let kinds = kinds.unwrap_or_else(|| AnalysisKind::ALL.to_vec());
self.live(move |idx| {
let analysis = idx.analysis();
let policy = CachePolicy::Default;
let mut report = SectionReport::new(&symbol);
for kind in kinds {
let name = kind.as_str();
match kind {
AnalysisKind::Technical => {
let source = idx.market().history_source(None);
report.add(name, analysis.technical(&symbol, source, policy))
}
AnalysisKind::Growth => report.add(name, analysis.growth(&symbol, policy)),
AnalysisKind::Valuation => {
report.add(name, analysis.valuation(&symbol, policy))
}
AnalysisKind::Risk => report.add(name, analysis.risk(&symbol, policy)),
}
}
report.finish()
})
.await
}
#[tool(
description = "Compare fundamental reports (growth, valuation, risk) across IDX \
tickers. Tickers that fail are listed under `failures`."
)]
async fn compare_stocks(&self, params: Parameters<SymbolsParams>) -> ToolResult {
let SymbolsParams { symbols } = params.0;
self.live(move |idx| {
let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?;
let failures: Vec<Value> = comparison
.failures
.iter()
.map(|(symbol, err)| {
let mut body = error_body(err);
body.insert("symbol".into(), json!(symbol));
Value::Object(body)
})
.collect();
let reports: &[Fetched<FundamentalReport>] = &comparison.reports;
Ok(json!({ "reports": reports, "failures": failures }))
})
.await
}
#[tool(
description = "Shareholders above 1% of an IDX ticker from the latest KSEI release, \
with concentration metrics (top1, top3, HHI, free float)."
)]
async fn get_shareholders(&self, params: Parameters<ShareholdersParams>) -> ToolResult {
let ShareholdersParams { ticker } = params.0;
self.local(move |investor| investor.ticker(&ticker, HolderSource::All))
.await
}
#[tool(description = "Every IDX holding (above 1%) of one investor, matched by name.")]
async fn get_investor_holdings(&self, params: Parameters<EntityParams>) -> ToolResult {
let EntityParams { entity } = params.0;
self.local(move |investor| investor.entity(&entity)).await
}
#[tool(description = "Search investor names in the KSEI ownership database.")]
async fn search_investors(&self, params: Parameters<SearchParams>) -> ToolResult {
let SearchParams { query, limit } = params.0;
self.local(move |investor| {
let entities = investor.search(&query, limit.unwrap_or(SEARCH_LIMIT))?;
Ok(json!({ "entities": entities }))
})
.await
}
#[tool(
description = "Holder changes (new, exit, increase, decrease) between two KSEI \
release dates, optionally for one ticker."
)]
async fn get_ownership_changes(&self, params: Parameters<ChangesParams>) -> ToolResult {
let ChangesParams {
from,
to,
ticker,
limit,
} = params.0;
self.local(move |investor| {
let mut changes = investor.changes(&from, &to, ticker.as_deref())?;
let total = changes.len();
changes.truncate(limit.unwrap_or(CHANGES_LIMIT));
Ok(json!({ "from": from, "to": to, "total": total, "changes": changes }))
})
.await
}
#[tool(
description = "Ownership database status: whether it has data, and the imported \
KSEI release dates."
)]
async fn ownership_status(&self) -> ToolResult {
let investor = Investor::new(&self.config);
blocking(move || investor.status()).await
}
#[tool(
description = "Download or refresh the KSEI ownership snapshot (several MB) into \
the local database."
)]
async fn ownership_sync(&self, params: Parameters<SyncParams>) -> ToolResult {
let SyncParams { force } = params.0;
let investor = Investor::new(&self.config);
blocking(move || investor.sync(None, force)).await
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for IdxServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("idx-mcp", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS)
}
}
/// Results keyed by section, with failed sections under `errors`.
struct SectionReport {
symbol: String,
sections: Map<String, Value>,
errors: Map<String, Value>,
last_error: Option<IdxError>,
}
impl SectionReport {
fn new(symbol: &str) -> Self {
Self {
symbol: symbol.to_string(),
sections: Map::new(),
errors: Map::new(),
last_error: None,
}
}
fn add<T: Serialize>(&mut self, name: &str, result: Result<T, IdxError>) {
match result.and_then(to_json) {
Ok(value) => {
self.sections.insert(name.to_string(), value);
}
Err(err) => {
self.errors
.insert(name.to_string(), Value::Object(error_body(&err)));
self.last_error = Some(err);
}
}
}
fn finish(self) -> Result<Value, IdxError> {
if self.sections.is_empty()
&& let Some(err) = self.last_error
{
return Err(err);
}
let mut out = Map::new();
out.insert("symbol".into(), json!(self.symbol));
out.extend(self.sections);
if !self.errors.is_empty() {
out.insert("errors".into(), Value::Object(self.errors));
}
Ok(Value::Object(out))
}
}
async fn blocking<T, F>(f: F) -> ToolResult
where
T: Serialize + Send + 'static,
F: FnOnce() -> Result<T, IdxError> + Send + 'static,
{
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))),
})
}
fn to_json<T: Serialize>(value: T) -> Result<Value, IdxError> {
serde_json::to_value(value).map_err(|err| IdxError::ParseError(err.to_string()))
}
/// The CLI's JSON error shape: `{error, code, message}`.
fn error_body(err: &IdxError) -> Map<String, Value> {
let mut body = Map::new();
body.insert("error".into(), json!(true));
body.insert("code".into(), json!(err.code().to_string()));
body.insert("message".into(), json!(err.to_string()));
body
}

337
crates/idx-mcp/tests/mcp.rs Normal file
View file

@ -0,0 +1,337 @@
//! 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; 11] = [
"analyze_stock",
"compare_stocks",
"get_company",
"get_investor_holdings",
"get_ownership_changes",
"get_price_history",
"get_quotes",
"get_shareholders",
"ownership_status",
"screen_stocks",
"search_investors",
];
struct Client {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
_home: tempdir::Dir,
}
impl Client {
fn start(args: &[&str], seed: bool) -> 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")
.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);
}
#[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(&quotes["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 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 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");
// 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);
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);
}
}
}