mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
5b8a26499d
commit
cda12fe2db
18 changed files with 1546 additions and 38 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
`idx-cli` is a Rust CLI (binary `idx`) for Indonesian stock market analysis
|
`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
|
## Navigation
|
||||||
```text
|
```text
|
||||||
|
|
@ -19,6 +20,8 @@ crates/idx-cli/ binary `idx`
|
||||||
├── src/config.rs IdxConfig: output format and color resolution
|
├── src/config.rs IdxConfig: output format and color resolution
|
||||||
├── src/output/ table.rs, json.rs, style.rs
|
├── src/output/ table.rs, json.rs, style.rs
|
||||||
└── tests/ cli.rs (behavior), golden.rs + golden/ (output parity)
|
└── 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
|
scripts/ smoke, installer, and ownership snapshot publisher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
387
Cargo.lock
generated
387
Cargo.lock
generated
|
|
@ -257,7 +257,7 @@ dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -410,6 +410,40 @@ dependencies = [
|
||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -427,7 +461,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -475,7 +509,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -487,6 +521,12 @@ dependencies = [
|
||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dyn-clone"
|
||||||
|
version = "1.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encode_unicode"
|
name = "encode_unicode"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -561,6 +601,94 @@ dependencies = [
|
||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
|
|
@ -582,6 +710,17 @@ dependencies = [
|
||||||
"wasi",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -733,6 +872,12 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -793,6 +938,19 @@ dependencies = [
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idx-mcp"
|
||||||
|
version = "0.2.4"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"idx-core",
|
||||||
|
"rmcp",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
|
|
@ -801,6 +959,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -984,12 +1144,24 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1068,6 +1240,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -1083,11 +1261,31 @@ version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
|
|
@ -1125,12 +1323,47 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.32.1"
|
version = "0.32.1"
|
||||||
|
|
@ -1211,6 +1444,32 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -1244,7 +1503,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -1292,6 +1562,12 @@ version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
|
|
@ -1327,6 +1603,17 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -1335,7 +1622,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1370,7 +1657,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1381,7 +1668,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1425,6 +1712,42 @@ dependencies = [
|
||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
|
|
@ -1466,6 +1789,37 @@ version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|
@ -1565,6 +1919,7 @@ version = "1.22.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
@ -1638,7 +1993,7 @@ dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1703,7 +2058,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1714,7 +2069,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1923,7 +2278,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1944,7 +2299,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1964,7 +2319,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2004,7 +2359,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/idx-core", "crates/idx-cli"]
|
members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -144,7 +144,7 @@ idx composes with `jq`, Python, and other shell tools:
|
||||||
```bash
|
```bash
|
||||||
idx stocks quote BBCA BBRI | jq -r '.[] | "\(.symbol) \(.price)"'
|
idx stocks quote BBCA BBRI | jq -r '.[] | "\(.symbol) \(.price)"'
|
||||||
idx -o jsonl stocks history BBCA --period 1y | tail -5
|
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
|
- 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"`;
|
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`.
|
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
|
## Documentation
|
||||||
|
|
||||||
- [docs/INSTALL.md](docs/INSTALL.md): install script options
|
- [docs/INSTALL.md](docs/INSTALL.md): install script options
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,11 @@ history lives in git.
|
||||||
## Workflow
|
## Workflow
|
||||||
- `watchlist`: named symbol sets usable by other commands
|
- `watchlist`: named symbol sets usable by other commands
|
||||||
- `alerts`: price alerts with a daemon
|
- `alerts`: price alerts with a daemon
|
||||||
- CSV/TSV and JSONL output
|
- CSV/TSV output
|
||||||
- Stdin symbol input for chaining (`... | idx stocks fundamental --stdin`)
|
|
||||||
- Additional providers (Alpha Vantage, Twelve Data, IDX official)
|
- Additional providers (Alpha Vantage, Twelve Data, IDX official)
|
||||||
|
|
||||||
## Agent integration
|
## 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
|
- JSON Schema export for command output
|
||||||
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
|
- Composite `analyze` command (quote, fundamentals, ownership, sentiment)
|
||||||
|
|
||||||
|
|
@ -29,5 +28,7 @@ history lives in git.
|
||||||
can still fail.
|
can still fail.
|
||||||
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
||||||
200 daily candles are returned.
|
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
|
- MSN history for IDX is price-only (`1mo`, `3mo`, `1y`); Yahoo stays the
|
||||||
default OHLCV source.
|
default OHLCV source.
|
||||||
|
|
|
||||||
|
|
@ -601,7 +601,7 @@ fn handle_changes(
|
||||||
args: &ChangesArgs,
|
args: &ChangesArgs,
|
||||||
config: &IdxConfig,
|
config: &IdxConfig,
|
||||||
) -> Result<(), IdxError> {
|
) -> Result<(), IdxError> {
|
||||||
let changes = investor.changes(&args.from, &args.to)?;
|
let changes = investor.changes(&args.from, &args.to, None)?;
|
||||||
|
|
||||||
if config.output.is_json() {
|
if config.output.is_json() {
|
||||||
return json::print_json(&changes);
|
return json::print_json(&changes);
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||||
}
|
}
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"error": true,
|
"error": true,
|
||||||
"code": format!("{:?}", err.code()).to_uppercase(),
|
"code": err.code().to_string(),
|
||||||
"message": err.to_string()
|
"message": err.to_string()
|
||||||
});
|
});
|
||||||
let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal();
|
let pretty = *format == OutputFormat::Json && std::io::stderr().is_terminal();
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,17 @@ pub enum Period {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::OneDay => "1d",
|
Self::OneDay => "1d",
|
||||||
|
|
@ -320,6 +331,8 @@ pub enum Interval {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Interval {
|
impl Interval {
|
||||||
|
pub const ALL: [Self; 3] = [Self::Day, Self::Week, Self::Month];
|
||||||
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Day => "1d",
|
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<Self, Self::Err> {
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
parse_named(value, "interval", &Self::ALL, Self::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_named<T: Clone>(
|
||||||
|
value: &str,
|
||||||
|
what: &str,
|
||||||
|
all: &[T],
|
||||||
|
name: fn(&T) -> &'static str,
|
||||||
|
) -> Result<T, crate::error::IdxError> {
|
||||||
|
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(", ")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,13 @@ pub enum ErrorCode {
|
||||||
NoMarketData,
|
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 {
|
impl IdxError {
|
||||||
pub fn code(&self) -> ErrorCode {
|
pub fn code(&self) -> ErrorCode {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::{NaiveDate, Utc};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
@ -55,6 +55,18 @@ impl Investor {
|
||||||
db::query_releases(&self.connect()?)
|
db::query_releases(&self.connect()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the database has data, and which releases it holds.
|
||||||
|
pub fn status(&self) -> Result<OwnershipStatus, IdxError> {
|
||||||
|
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
|
/// Holders of `symbol` in the latest release, optionally limited to one
|
||||||
/// source (ranks and concentration are recomputed for that source).
|
/// source (ranks and concentration are recomputed for that source).
|
||||||
pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result<TickerOwnership, IdxError> {
|
pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result<TickerOwnership, IdxError> {
|
||||||
|
|
@ -132,8 +144,19 @@ impl Investor {
|
||||||
db::query_bing_flow(&conn, ticker_id)
|
db::query_bing_flow(&conn, ticker_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn changes(&self, from: &str, to: &str) -> Result<Vec<ChangeRow>, IdxError> {
|
/// Holder changes between two release dates, optionally for one ticker.
|
||||||
db::query_changes(&self.connect()?, from, to)
|
pub fn changes(
|
||||||
|
&self,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
ticker: Option<&str>,
|
||||||
|
) -> Result<Vec<ChangeRow>, 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(
|
pub fn graph(
|
||||||
|
|
@ -380,6 +403,15 @@ pub struct MergeResult {
|
||||||
pub bing_holdings_updates: i64,
|
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<NaiveDate>,
|
||||||
|
pub releases: Vec<OwnershipRelease>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Holder sources for `Investor::ticker`.
|
/// Holder sources for `Investor::ticker`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum HolderSource {
|
pub enum HolderSource {
|
||||||
|
|
|
||||||
31
crates/idx-mcp/Cargo.toml
Normal file
31
crates/idx-mcp/Cargo.toml
Normal 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
1
crates/idx-mcp/LICENSE
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../LICENSE
|
||||||
51
crates/idx-mcp/src/main.rs
Normal file
51
crates/idx-mcp/src/main.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
crates/idx-mcp/src/params.rs
Normal file
177
crates/idx-mcp/src/params.rs
Normal 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())
|
||||||
|
}
|
||||||
391
crates/idx-mcp/src/server.rs
Normal file
391
crates/idx-mcp/src/server.rs
Normal 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
337
crates/idx-mcp/tests/mcp.rs
Normal 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("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# System Design: idx-core and its frontends
|
# 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
|
## Goal
|
||||||
|
|
||||||
|
|
@ -228,9 +228,25 @@ keeps the tool list short for agents:
|
||||||
| `get_ownership_changes` | `from`, `to`, `ticker?` | `investor().changes` |
|
| `get_ownership_changes` | `from`, `to`, `ticker?` | `investor().changes` |
|
||||||
| `ownership_status` | none | `investor().status` |
|
| `ownership_status` | none | `investor().status` |
|
||||||
|
|
||||||
Ownership bootstrap: if the local DB is empty, investor tools return an error
|
Ownership bootstrap: if the local DB is empty, investor tools return a
|
||||||
telling the agent to call `ownership_sync`. That tool is opt-in through a
|
`NOMARKETDATA` error telling the agent to call `ownership_sync`. That tool is
|
||||||
server flag, because it downloads several MB.
|
listed only when the server runs with `--allow-sync`, because it downloads
|
||||||
|
several MB.
|
||||||
|
|
||||||
|
As built (phase 4):
|
||||||
|
|
||||||
|
- Live tools return `Fetched<T>` 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)
|
## 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
|
1. Are the exit-code classes right, and should `compare` with partial results
|
||||||
still exit `0`?
|
still exit `0`?
|
||||||
2. MCP tool granularity: is the grouped table above right, or should it be one
|
2. Which source should back IHSG index, breadth, and sector data?
|
||||||
tool per CLI command?
|
3. Is idx-api self-hosted only?
|
||||||
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?
|
|
||||||
|
|
|
||||||
44
flake.nix
44
flake.nix
|
|
@ -37,10 +37,12 @@
|
||||||
./crates/idx-core/tests/fixtures
|
./crates/idx-core/tests/fixtures
|
||||||
./crates/idx-cli/Cargo.toml
|
./crates/idx-cli/Cargo.toml
|
||||||
./crates/idx-cli/src
|
./crates/idx-cli/src
|
||||||
|
./crates/idx-mcp/Cargo.toml
|
||||||
|
./crates/idx-mcp/src
|
||||||
];
|
];
|
||||||
testSrc = lib.fileset.toSource {
|
testSrc = lib.fileset.toSource {
|
||||||
root = ./.;
|
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 {
|
src = lib.fileset.toSource {
|
||||||
root = ./.;
|
root = ./.;
|
||||||
|
|
@ -65,6 +67,7 @@
|
||||||
idxPackage = craneLib.buildPackage (commonArgs // {
|
idxPackage = craneLib.buildPackage (commonArgs // {
|
||||||
cargoArtifacts = releaseCargoArtifacts;
|
cargoArtifacts = releaseCargoArtifacts;
|
||||||
CARGO_PROFILE = "release";
|
CARGO_PROFILE = "release";
|
||||||
|
cargoExtraArgs = "--locked -p idx-cli";
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ];
|
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ];
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
|
|
@ -72,6 +75,19 @@
|
||||||
--prefix PATH : "${lib.makeBinPath runtimeDeps}"
|
--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
|
# Self-contained ownership snapshot publisher for scheduled hosts
|
||||||
# (e.g. a NixOS systemd timer): no checkout, `nix develop` or cargo
|
# (e.g. a NixOS systemd timer): no checkout, `nix develop` or cargo
|
||||||
# build at run time. Wraps the repo scripts with a pinned `idx`.
|
# build at run time. Wraps the repo scripts with a pinned `idx`.
|
||||||
|
|
@ -147,6 +163,7 @@
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages.default = idxPackage;
|
packages.default = idxPackage;
|
||||||
|
packages.idx-mcp = mcpPackage;
|
||||||
packages.ownership-publisher = ownershipPublisher;
|
packages.ownership-publisher = ownershipPublisher;
|
||||||
apps.default = {
|
apps.default = {
|
||||||
type = "app";
|
type = "app";
|
||||||
|
|
@ -155,6 +172,7 @@
|
||||||
};
|
};
|
||||||
checks = {
|
checks = {
|
||||||
default = idxPackage;
|
default = idxPackage;
|
||||||
|
idx-mcp = mcpPackage;
|
||||||
ownership-publisher = pkgs.runCommand "idx-ownership-publisher-smoke" { } ''
|
ownership-publisher = pkgs.runCommand "idx-ownership-publisher-smoke" { } ''
|
||||||
${ownershipPublisher}/bin/idx-ownership-publish --help
|
${ownershipPublisher}/bin/idx-ownership-publish --help
|
||||||
touch "$out"
|
touch "$out"
|
||||||
|
|
@ -187,6 +205,8 @@
|
||||||
./crates/idx-cli/tests
|
./crates/idx-cli/tests
|
||||||
./crates/idx-core/LICENSE
|
./crates/idx-core/LICENSE
|
||||||
./crates/idx-cli/LICENSE
|
./crates/idx-cli/LICENSE
|
||||||
|
./crates/idx-mcp/tests
|
||||||
|
./crates/idx-mcp/LICENSE
|
||||||
./LICENSE
|
./LICENSE
|
||||||
./README.md
|
./README.md
|
||||||
]);
|
]);
|
||||||
|
|
@ -208,6 +228,13 @@
|
||||||
(cd "$verify/idx-cli-${version}" && cargo build --offline \
|
(cd "$verify/idx-cli-${version}" && cargo build --offline \
|
||||||
--config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \
|
--config 'patch.crates-io.idx-core.path="../idx-core-${version}"' \
|
||||||
--target-dir "$verify/target")
|
--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;
|
doInstallCargoArtifacts = false;
|
||||||
installPhaseCommand = "touch $out";
|
installPhaseCommand = "touch $out";
|
||||||
|
|
@ -230,6 +257,21 @@
|
||||||
jq -e '.code == "CACHEMISS"' err.json
|
jq -e '.code == "CACHEMISS"' err.json
|
||||||
touch "$out"
|
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-sh = installerCheck "sh";
|
||||||
install-script-dash = installerCheck "dash";
|
install-script-dash = installerCheck "dash";
|
||||||
shellcheck = pkgs.runCommand "idx-shellcheck" {
|
shellcheck = pkgs.runCommand "idx-shellcheck" {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue