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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue