diff --git a/AGENTS.md b/AGENTS.md index b8a03c3..e3851bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # AGENTS.md `idx-cli` is a Rust CLI (binary `idx`) for Indonesian stock market analysis -(`stocks`) and KSEI shareholder ownership (`ownership`). +(`stocks`) and KSEI shareholder ownership (`ownership`), plus an MCP server +(binary `idx-mcp`) over the same library. ## Navigation ```text @@ -19,6 +20,8 @@ crates/idx-cli/ binary `idx` ├── src/config.rs IdxConfig: output format and color resolution ├── src/output/ table.rs, json.rs, style.rs └── tests/ cli.rs (behavior), golden.rs + golden/ (output parity) +crates/idx-mcp/ binary `idx-mcp`: stdio MCP tools (server.rs), inputs (params.rs) +└── tests/mcp.rs JSON-RPC over stdio against the mock provider scripts/ smoke, installer, and ownership snapshot publisher ``` diff --git a/Cargo.lock b/Cargo.lock index a0474da..e88b191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,7 +257,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -410,6 +410,40 @@ dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + [[package]] name = "deranged" version = "0.5.8" @@ -427,7 +461,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -475,7 +509,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -487,6 +521,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -561,6 +601,94 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -582,6 +710,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -733,6 +872,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -793,6 +938,19 @@ dependencies = [ "zip", ] +[[package]] +name = "idx-mcp" +version = "0.2.4" +dependencies = [ + "clap", + "idx-core", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -801,6 +959,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -984,12 +1144,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1068,6 +1240,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1083,11 +1261,31 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "regex" version = "1.12.3" @@ -1125,12 +1323,47 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.9", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1211,6 +1444,32 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1244,7 +1503,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] @@ -1292,6 +1562,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1327,6 +1603,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1335,7 +1622,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1370,7 +1657,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1381,7 +1668,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1425,6 +1712,42 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1466,6 +1789,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1565,6 +1919,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -1638,7 +1993,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1703,7 +2058,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1714,7 +2069,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1923,7 +2278,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1944,7 +2299,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1964,7 +2319,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2004,7 +2359,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 68c9bd6..03b6714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/idx-core", "crates/idx-cli"] +members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"] [workspace.package] version = "0.2.4" diff --git a/README.md b/README.md index ce7de8d..8877a0c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ idx composes with `jq`, Python, and other shell tools: ```bash idx stocks quote BBCA BBRI | jq -r '.[] | "\(.symbol) \(.price)"' idx -o jsonl stocks history BBCA --period 1y | tail -5 -idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare - +idx stocks screen --filter high-dividend | jq -r '.[].symbol' | idx stocks compare - ``` - stdout carries only data; warnings and errors go to stderr @@ -157,6 +157,27 @@ idx stocks screen --filter low-pe | jq -r '.[].symbol' | idx stocks compare - If you ran `idx config init` before v0.3, your config pins `output = "table"`; change it to `auto` with `idx config set general.output auto`. +## MCP server + +`idx-mcp` serves the same data to MCP clients over stdio, as 11 tools: +`get_quotes`, `get_price_history`, `screen_stocks`, `get_company`, +`analyze_stock`, `compare_stocks`, `get_shareholders`, +`get_investor_holdings`, `search_investors`, `get_ownership_changes`, and +`ownership_status`. Live results come wrapped as +`{data, source, fetched_at, stale}`; errors use the CLI's `{error, code, +message}` body. It shares idx's config, cache, and ownership database. + +Until it is released, build it from this repo: + +```bash +cargo install --path crates/idx-mcp # or: nix build .#idx-mcp +claude mcp add idx -- idx-mcp # Claude Code; other clients run `idx-mcp` +``` + +Ownership tools need a populated database: run `idx ownership sync` once, or +start the server with `idx-mcp --allow-sync` to expose an `ownership_sync` +tool (it downloads several MB). + ## Documentation - [docs/INSTALL.md](docs/INSTALL.md): install script options diff --git a/ROADMAP.md b/ROADMAP.md index 8813789..7380e4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,12 +15,11 @@ history lives in git. ## Workflow - `watchlist`: named symbol sets usable by other commands - `alerts`: price alerts with a daemon -- CSV/TSV and JSONL output -- Stdin symbol input for chaining (`... | idx stocks fundamental --stdin`) +- CSV/TSV output - Additional providers (Alpha Vantage, Twelve Data, IDX official) ## Agent integration -- MCP server exposing the existing commands as tools (planned as `idx-mcp`) +- Publish `idx-mcp` (crates.io, npm, release assets); see `docs/DESIGN.md` phase 5 - JSON Schema export for command output - Composite `analyze` command (quote, fundamentals, ownership, sentiment) @@ -29,5 +28,7 @@ history lives in git. can still fail. - `stocks technical` reports the SMA200 trend as unavailable when fewer than 200 daily candles are returned. +- MSN's `low-pe` screen returned no priced stocks on 2026-10-14 (upstream; + other filters work). - MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the default OHLCV source. diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index 7836c5b..d9b4261 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -601,7 +601,7 @@ fn handle_changes( args: &ChangesArgs, config: &IdxConfig, ) -> Result<(), IdxError> { - let changes = investor.changes(&args.from, &args.to)?; + let changes = investor.changes(&args.from, &args.to, None)?; if config.output.is_json() { return json::print_json(&changes); diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 212a104..2f1b8e4 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -215,7 +215,7 @@ pub fn emit_error(err: &IdxError, format: &OutputFormat) { } let payload = serde_json::json!({ "error": true, - "code": format!("{:?}", err.code()).to_uppercase(), + "code": err.code().to_string(), "message": err.to_string() }); let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal(); diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index 130046d..bf2552c 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -294,6 +294,17 @@ pub enum Period { } impl Period { + pub const ALL: [Self; 8] = [ + Self::OneDay, + Self::FiveDays, + Self::OneMonth, + Self::ThreeMonths, + Self::SixMonths, + Self::OneYear, + Self::TwoYears, + Self::FiveYears, + ]; + pub fn as_str(&self) -> &'static str { match self { Self::OneDay => "1d", @@ -320,6 +331,8 @@ pub enum Interval { } impl Interval { + pub const ALL: [Self; 3] = [Self::Day, Self::Week, Self::Month]; + pub fn as_str(&self) -> &'static str { match self { Self::Day => "1d", @@ -328,3 +341,37 @@ impl Interval { } } } + +impl std::str::FromStr for Period { + type Err = crate::error::IdxError; + + fn from_str(value: &str) -> Result { + parse_named(value, "period", &Self::ALL, Self::as_str) + } +} + +impl std::str::FromStr for Interval { + type Err = crate::error::IdxError; + + fn from_str(value: &str) -> Result { + parse_named(value, "interval", &Self::ALL, Self::as_str) + } +} + +fn parse_named( + value: &str, + what: &str, + all: &[T], + name: fn(&T) -> &'static str, +) -> Result { + all.iter() + .find(|item| name(item) == value) + .cloned() + .ok_or_else(|| { + let names: Vec<_> = all.iter().map(name).collect(); + crate::error::IdxError::InvalidInput(format!( + "invalid {what} '{value}' (expected one of: {})", + names.join(", ") + )) + }) +} diff --git a/crates/idx-core/src/error.rs b/crates/idx-core/src/error.rs index 7df9dc2..d991c6b 100644 --- a/crates/idx-core/src/error.rs +++ b/crates/idx-core/src/error.rs @@ -55,6 +55,13 @@ pub enum ErrorCode { NoMarketData, } +/// The stable wire form frontends emit, e.g. `SYMBOLNOTFOUND`. +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}").to_uppercase()) + } +} + impl IdxError { pub fn code(&self) -> ErrorCode { match self { diff --git a/crates/idx-core/src/investor.rs b/crates/idx-core/src/investor.rs index eaef76b..c8285dc 100644 --- a/crates/idx-core/src/investor.rs +++ b/crates/idx-core/src/investor.rs @@ -7,7 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; -use chrono::Utc; +use chrono::{NaiveDate, Utc}; use rusqlite::Connection; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -55,6 +55,18 @@ impl Investor { db::query_releases(&self.connect()?) } + /// Whether the database has data, and which releases it holds. + pub fn status(&self) -> Result { + let db_path = self.db_path()?; + let releases = db::query_releases(&db::open_db_at(&db_path)?)?; + Ok(OwnershipStatus { + db_path: db_path.display().to_string(), + populated: !releases.is_empty(), + latest_as_of_date: releases.iter().map(|r| r.as_of_date).max(), + releases, + }) + } + /// Holders of `symbol` in the latest release, optionally limited to one /// source (ranks and concentration are recomputed for that source). pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result { @@ -132,8 +144,19 @@ impl Investor { db::query_bing_flow(&conn, ticker_id) } - pub fn changes(&self, from: &str, to: &str) -> Result, IdxError> { - db::query_changes(&self.connect()?, from, to) + /// Holder changes between two release dates, optionally for one ticker. + pub fn changes( + &self, + from: &str, + to: &str, + ticker: Option<&str>, + ) -> Result, IdxError> { + let mut rows = db::query_changes(&self.connect()?, from, to)?; + if let Some(ticker) = ticker { + let ticker = ticker.trim().to_uppercase(); + rows.retain(|row| row.ticker_code == ticker); + } + Ok(rows) } pub fn graph( @@ -380,6 +403,15 @@ pub struct MergeResult { pub bing_holdings_updates: i64, } +#[derive(Debug, Clone, Serialize)] +pub struct OwnershipStatus { + pub db_path: String, + /// False until `sync` or `import` has loaded at least one release. + pub populated: bool, + pub latest_as_of_date: Option, + pub releases: Vec, +} + /// Holder sources for `Investor::ticker`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HolderSource { diff --git a/crates/idx-mcp/Cargo.toml b/crates/idx-mcp/Cargo.toml new file mode 100644 index 0000000..c055a91 --- /dev/null +++ b/crates/idx-mcp/Cargo.toml @@ -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"] } diff --git a/crates/idx-mcp/LICENSE b/crates/idx-mcp/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/idx-mcp/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/idx-mcp/src/main.rs b/crates/idx-mcp/src/main.rs new file mode 100644 index 0000000..19b2c10 --- /dev/null +++ b/crates/idx-mcp/src/main.rs @@ -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 + } + } +} diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs new file mode 100644 index 0000000..74e3fee --- /dev/null +++ b/crates/idx-mcp/src/params.rs @@ -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, +} + +#[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, + /// Candle size (default "1d"). + #[serde(default)] + #[schemars(schema_with = "interval_schema")] + pub interval: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ScreenParams { + /// Screen to run (default "top-performers"). + #[serde(default)] + #[schemars(schema_with = "filter_schema")] + pub filter: Option, + /// Market region (default "id"). + #[serde(default)] + #[schemars(schema_with = "region_schema")] + pub region: Option, + /// Maximum results (default 50). + #[serde(default)] + pub limit: Option, +} + +#[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>, +} + +#[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>, +} + +#[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, +} + +#[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, + /// Maximum rows returned (default 200); `total` reports the full count. + #[serde(default)] + pub limit: Option, +} + +#[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()) +} diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs new file mode 100644 index 0000000..e9412d2 --- /dev/null +++ b/crates/idx-mcp/src/server.rs @@ -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; + +#[derive(Clone)] +pub struct IdxServer { + config: CoreConfig, + allow_sync: bool, + tool_router: ToolRouter, +} + +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(&self, f: F) -> ToolResult + where + T: Serialize + Send + 'static, + F: FnOnce(&Idx) -> Result + 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(&self, f: F) -> ToolResult + where + T: Serialize + Send + 'static, + F: FnOnce(&Investor) -> Result + 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) -> 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) -> 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) -> ToolResult { + let ScreenParams { + filter, + region, + limit, + } = params.0; + self.live(move |idx| { + let query = ScreenQuery { + filter: filter + .as_deref() + .unwrap_or("top-performers") + .parse::()?, + region: region.as_deref().unwrap_or("id").parse::()?, + 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) -> 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) -> 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) -> ToolResult { + let SymbolsParams { symbols } = params.0; + self.live(move |idx| { + let comparison = idx.analysis().compare(&symbols, CachePolicy::Default)?; + let failures: Vec = 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] = &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) -> 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) -> 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) -> 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) -> 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) -> 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, + errors: Map, + last_error: Option, +} + +impl SectionReport { + fn new(symbol: &str) -> Self { + Self { + symbol: symbol.to_string(), + sections: Map::new(), + errors: Map::new(), + last_error: None, + } + } + + fn add(&mut self, name: &str, result: Result) { + 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 { + 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(f: F) -> ToolResult +where + T: Serialize + Send + 'static, + F: FnOnce() -> Result + 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(value: T) -> Result { + 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 { + 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 +} diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs new file mode 100644 index 0000000..8698570 --- /dev/null +++ b/crates/idx-mcp/tests/mcp.rs @@ -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, + 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 { + let result = self.request("tools/list", json!({})); + let mut names: Vec = 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::(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("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 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); + } + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c726772..fe57bcb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # System Design: idx-core and its frontends -**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-3 done. +**Status:** Accepted 2026-10-14. Implementation on `refactor/idx-core`; phases 0-4 done. ## Goal @@ -228,9 +228,25 @@ keeps the tool list short for agents: | `get_ownership_changes` | `from`, `to`, `ticker?` | `investor().changes` | | `ownership_status` | none | `investor().status` | -Ownership bootstrap: if the local DB is empty, investor tools return an error -telling the agent to call `ownership_sync`. That tool is opt-in through a -server flag, because it downloads several MB. +Ownership bootstrap: if the local DB is empty, investor tools return a +`NOMARKETDATA` error telling the agent to call `ownership_sync`. That tool is +listed only when the server runs with `--allow-sync`, because it downloads +several MB. + +As built (phase 4): + +- Live tools return `Fetched` objects; `get_quotes` wraps its list as + `{quotes}`. `get_company` and `analyze_stock` return one key per section + (default: all) and list failed sections under `errors`, failing only if every + section fails. +- `get_ownership_changes` returns at most `limit` rows (default 200) with the + full count in `total`. +- Each call runs on a blocking thread with a fresh `Idx`, so tools share the + CLI's config, cache, and database. With a shared cache, tool data equals + the CLI's JSON value for value (checked live for every tool); object keys + come out sorted. +- `crates/idx-mcp/tests/mcp.rs` drives the binary over stdio JSON-RPC with + the mock provider; `nix flake check` also smoke-tests the packaged server. ## Frontend: idx-api (later) @@ -324,9 +340,5 @@ every phase passes both parity gates. 1. Are the exit-code classes right, and should `compare` with partial results still exit `0`? -2. MCP tool granularity: is the grouped table above right, or should it be one - tool per CLI command? -3. Should `ownership_sync` be available to agents by default? -4. Which source should back IHSG index, breadth, and sector data, and should it - land before or after idx-mcp? -5. Is idx-api self-hosted only? +2. Which source should back IHSG index, breadth, and sector data? +3. Is idx-api self-hosted only? diff --git a/flake.nix b/flake.nix index 2ede4f4..9c53422 100644 --- a/flake.nix +++ b/flake.nix @@ -37,10 +37,12 @@ ./crates/idx-core/tests/fixtures ./crates/idx-cli/Cargo.toml ./crates/idx-cli/src + ./crates/idx-mcp/Cargo.toml + ./crates/idx-mcp/src ]; testSrc = lib.fileset.toSource { root = ./.; - fileset = lib.fileset.unions (rustFiles ++ [ ./crates/idx-cli/tests ]); + fileset = lib.fileset.unions (rustFiles ++ [ ./crates/idx-cli/tests ./crates/idx-mcp/tests ]); }; src = lib.fileset.toSource { root = ./.; @@ -65,6 +67,7 @@ idxPackage = craneLib.buildPackage (commonArgs // { cargoArtifacts = releaseCargoArtifacts; CARGO_PROFILE = "release"; + cargoExtraArgs = "--locked -p idx-cli"; doCheck = false; nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; postInstall = '' @@ -72,6 +75,19 @@ --prefix PATH : "${lib.makeBinPath runtimeDeps}" ''; }); + mcpPackage = craneLib.buildPackage (commonArgs // { + pname = "idx-mcp"; + cargoArtifacts = releaseCargoArtifacts; + CARGO_PROFILE = "release"; + cargoExtraArgs = "--locked -p idx-mcp"; + doCheck = false; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; + postInstall = '' + wrapProgram "$out/bin/idx-mcp" \ + --prefix PATH : "${lib.makeBinPath runtimeDeps}" + ''; + meta.mainProgram = "idx-mcp"; + }); # Self-contained ownership snapshot publisher for scheduled hosts # (e.g. a NixOS systemd timer): no checkout, `nix develop` or cargo # build at run time. Wraps the repo scripts with a pinned `idx`. @@ -147,6 +163,7 @@ in { packages.default = idxPackage; + packages.idx-mcp = mcpPackage; packages.ownership-publisher = ownershipPublisher; apps.default = { type = "app"; @@ -155,6 +172,7 @@ }; checks = { default = idxPackage; + idx-mcp = mcpPackage; ownership-publisher = pkgs.runCommand "idx-ownership-publisher-smoke" { } '' ${ownershipPublisher}/bin/idx-ownership-publish --help touch "$out" @@ -187,6 +205,8 @@ ./crates/idx-cli/tests ./crates/idx-core/LICENSE ./crates/idx-cli/LICENSE + ./crates/idx-mcp/tests + ./crates/idx-mcp/LICENSE ./LICENSE ./README.md ]); @@ -208,6 +228,13 @@ (cd "$verify/idx-cli-${version}" && cargo build --offline \ --config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \ --target-dir "$verify/target") + # idx-mcp: same as idx-cli. + cargo package -p idx-mcp --registry crates-io --no-verify --exclude-lockfile --offline --allow-dirty + tar -xzf target/package/idx-mcp-${version}.crate -C "$verify" + cp Cargo.lock "$verify/idx-mcp-${version}/" + (cd "$verify/idx-mcp-${version}" && cargo build --offline \ + --config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \ + --target-dir "$verify/target") ''; doInstallCargoArtifacts = false; installPhaseCommand = "touch $out"; @@ -230,6 +257,21 @@ jq -e '.code == "CACHEMISS"' err.json touch "$out" ''; + # The packaged server answers initialize and lists its tools over + # stdio; tool behavior is covered by crates/idx-mcp/tests. + packaged-mcp = pkgs.runCommand "idx-packaged-mcp" + { nativeBuildInputs = with pkgs; [ coreutils jq ]; } + '' + export HOME="$TMPDIR" XDG_CACHE_HOME="$TMPDIR/cache" XDG_CONFIG_HOME="$TMPDIR/config" XDG_DATA_HOME="$TMPDIR/data" + { + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' + echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + sleep 2 + } | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl + jq -se 'map(select(.id == 2))[0].result.tools | length == 11' out.jsonl + touch "$out" + ''; install-script-sh = installerCheck "sh"; install-script-dash = installerCheck "dash"; shellcheck = pkgs.runCommand "idx-shellcheck" {