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

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

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

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

View file

@ -7,7 +7,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use chrono::Utc;
use chrono::{NaiveDate, Utc};
use rusqlite::Connection;
use serde::Serialize;
use sha2::{Digest, Sha256};
@ -55,6 +55,18 @@ impl Investor {
db::query_releases(&self.connect()?)
}
/// Whether the database has data, and which releases it holds.
pub fn status(&self) -> Result<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
/// source (ranks and concentration are recomputed for that source).
pub fn ticker(&self, symbol: &str, source: HolderSource) -> Result<TickerOwnership, IdxError> {
@ -132,8 +144,19 @@ impl Investor {
db::query_bing_flow(&conn, ticker_id)
}
pub fn changes(&self, from: &str, to: &str) -> Result<Vec<ChangeRow>, IdxError> {
db::query_changes(&self.connect()?, from, to)
/// Holder changes between two release dates, optionally for one ticker.
pub fn changes(
&self,
from: &str,
to: &str,
ticker: Option<&str>,
) -> Result<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(
@ -380,6 +403,15 @@ pub struct MergeResult {
pub bing_holdings_updates: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct OwnershipStatus {
pub db_path: String,
/// False until `sync` or `import` has loaded at least one release.
pub populated: bool,
pub latest_as_of_date: Option<NaiveDate>,
pub releases: Vec<OwnershipRelease>,
}
/// Holder sources for `Investor::ticker`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HolderSource {