MF-NAV-MCP
README.md
# NAV Analytics MCP Server (standalone)
A self-contained MCP server that exposes Indian mutual fund NAV analytics as
tools, querying **parquet** data through **DuckDB** — local files or Azure Blob.
No external package or framework of yours is required. The server runs only
read-only `SELECT`s and never fabricates data.
Tested with `mcp==1.28.1`, `duckdb==1.5.4`, `python-dateutil==2.9.0.post0`.
## Files
server.py the MCP server (all tool logic; self-contained)
make_sample_data.py SYNTHETIC fixture generator — dev/smoke-test only
requirements.txt pinned deps
## Install
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
## Data
The server expects two parquet sources with this schema:
nav_history scheme_code VARCHAR, nav_date DATE, nav DOUBLE
scheme_master scheme_code VARCHAR, scheme_name VARCHAR,
fund_house VARCHAR, category VARCHAR
Point it at data with environment variables (defaults shown):
NAV_HISTORY_PATH ./data/nav_history.parquet
SCHEME_MASTER_PATH ./data/scheme_master.parquet
**No data yet?** Generate a synthetic fixture to prove the server runs:
python make_sample_data.py # writes ./data/*.parquet
The fixture is a random walk — **not real fund data**. Replace it with real
AMFI-derived parquet before trusting any number.
**Azure Blob.** Set a connection string and the paths may be `az://` URLs; the
server loads DuckDB's `azure` extension and registers a secret automatically:
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=...;AccountKey=...;"
export NAV_HISTORY_PATH="az://mycontainer/nav/*.parquet"
export SCHEME_MASTER_PATH="az://mycontainer/scheme_master.parquet"
## Run
python server.py # streamable HTTP at /mcp (default)
MCP_TRANSPORT=stdio python server.py # stdio (for local Cursor / Claude Desktop)
The HTTP mode is what you host as a **claude.ai custom connector** (below). The
stdio mode is the original local mode for Cursor / Claude Desktop.
## Host it for the team as a claude.ai connector
Goal: your team uses this in the **claude.ai web app**, plug-and-play — each
teammate adds one URL, nobody installs Python or handles the Azure key. That
means hosting the server once over HTTPS with OAuth.
### 1. Rotate the Azure key first
The old `.cursor/mcp.json` committed a live Storage account key. **Rotate it in
the Azure portal.** The new key goes only into the host's settings below — never
into a committed file.
### 2. Deploy (Azure App Service)
A `Dockerfile` is included. Build/push the image (or use App Service's
build-from-source), then set these **application settings**:
AZURE_STORAGE_CONNECTION_STRING <rotated connection string>
NAV_HISTORY_PATH az://mfnavdata/processed/nav_history/year=*/*.parquet
SCHEME_MASTER_PATH az://mfnavdata/processed/scheme_master.parquet
PUBLIC_BASE_URL https://<your-app>.azurewebsites.net
App Service terminates HTTPS and injects `$PORT` automatically.
**Auth.** Setting `PUBLIC_BASE_URL` turns on OAuth: the server runs its own
self-contained OAuth authorization server (fastmcp's `InMemoryOAuthProvider`) and
advertises the discovery endpoints claude.ai probes — **no Azure App
Registration, external identity provider, or scope configuration required.**
claude.ai registers itself dynamically and completes the handshake. Any client
that completes the flow is granted access, so this gates on *knowing the URL* plus
the OAuth handshake — appropriate for a small trusted team behind a URL you
control. (Tokens are in-memory: a server restart means teammates click "reconnect"
once. To add real per-user identity + revocation later, swap in a hosted provider
— Google/GitHub/WorkOS — by editing only `_build_auth()` in `server.py`.)
If `PUBLIC_BASE_URL` is **unset**, the server runs **unauthenticated** — use that
only for local testing, never public hosting.
### 3. Each teammate adds the connector (one-time)
In claude.ai → **Settings → Connectors → Add custom connector** → paste:
https://<your-app>.azurewebsites.net/mcp
Complete the Entra sign-in when prompted. The 5 NAV tools then appear in the
connector picker in any chat. (Personal claude.ai plans can't push org-wide, so
each teammate does this once — but that's the whole setup.)
## Register with a local client (stdio)
For local use in **Cursor** (`.cursor/mcp.json`) or **Claude Desktop**
(`claude_desktop_config.json`) — set `MCP_TRANSPORT=stdio`:
```json
{
"mcpServers": {
"nav-analytics": {
"command": "/absolute/path/to/venv/bin/python",
"args": ["/absolute/path/to/server.py"],
"env": {
"MCP_TRANSPORT": "stdio",
"NAV_HISTORY_PATH": "/absolute/path/to/data/nav_history.parquet",
"SCHEME_MASTER_PATH": "/absolute/path/to/data/scheme_master.parquet"
}
}
}
}
```
Use absolute paths for `command`, `args`, and any file paths; MCP clients don't
run in your project directory. For Azure, put the connection string in `env`
instead of the local paths.
## Tools
- **search_funds(query, limit=10)** — fuzzy fund-name → scheme_code resolver.
Call this first when the user names a fund; feed the resulting code into the
returns tools. Returns Regular/Direct and Growth/IDCW variants separately.
- **get_fund_returns(scheme_codes, period)** — point-to-point `return_pct` and
annualized `cagr_pct` for one or many funds. `cagr_pct` is populated only for
windows longer than a year (2Y/3Y/5Y, and SI when the fund is >1yr old); it is
`null` for shorter windows.
- **get_fund_returns_between(scheme_codes, start_date, end_date)** — the same
numbers over an **explicit** ISO `YYYY-MM-DD` range instead of a named period,
for when the user gives actual dates. Unlike the named periods, the window is
the same absolute pair for every fund rather than anchored per-fund. Both ends
snap to the latest NAV on/before the requested date, so the realized window can
be a day or two narrower — read `start_nav_date` / `end_nav_date` for what was
actually used. `cagr_pct` follows the realized duration (>1yr). A `start_date`
before a fund's inception gives that fund an error row rather than silently
starting at inception.
- **get_category_returns(category, period, sort_by, ascending, staleness_days=7)**
— returns for every fund in a category, ranked, with a staleness guard.
- **list_categories()** / **list_funds_in_category(category)** — discovery.
Periods: `1W 2W 1M 3M 6M 9M 1Y 2Y 3Y 5Y YTD MTD SI`.
### Benchmark indices
Three more tools mirror the fund ones for market indices, so a fund and its
benchmark can be compared over an identical window:
- **list_indices()** — the available indices with ticker, name and history span.
- **get_index_returns(tickers, period)** — mirrors `get_fund_returns`: same
period strings, same window conventions, same maths.
- **get_index_returns_between(tickers, start_date, end_date)** — mirrors
`get_fund_returns_between`.
**109 indices** are covered, with daily closes back to **1990** — broad market
(Nifty 50/100/200/500, Midcap, Smallcap, Microcap), sectoral (Bank, IT, Pharma,
Auto, FMCG, Metal, Realty, Energy…), factor (Momentum, Quality, Value, Alpha,
Low Volatility) and thematic (Defence, Railways, EV, Digital, Tourism).
Tickers are derived from the index name — `Nifty Midcap 150` → `NIFTY_MIDCAP_150`
— and `list_indices()` advertises the full set. Common shorthands are aliased
(`NIFTY50`, `NIFTY500`, `MIDCAP150`, `BANKNIFTY`, `NIFTYIT`, `SMALLCAP250`,
`VIX`, …); any exact ticker works without an alias. The source is NSE-only, so
there is **no BSE Sensex** series.
**Index data is committed to this repo**, not fetched at runtime — see
`data/index_history.parquet`. The server reads it as a plain local file, so
there is no network call in the request path. Two scripts maintain it, both run
from the project root:
pip install -r requirements-dev.txt
# Full rebuild from the Weekly Market Pulse Tracker workbook (put it in Index/)
python Index/parse_index_xlsx.py
# Daily top-up from the NSE Index_close_<date>.csv export (put it in Index/)
python Index/append_daily_close.py
git add data/index_history.parquet data/index_master.parquet
The daily script only updates indices already present, is safe to re-run (it
skips rows it already has), and writes atomically. The source workbook and CSV
are gitignored — only their parquet output is committed, so the data is as
current as the last deploy.
`Index/fetch_index_data.py` is the earlier Yahoo Finance fetcher, kept for
ad-hoc use. It is no longer the source of the committed parquet: Yahoo carried
5 indices from 2007, and served the sector indices too sparsely to trust.
> **Price return vs total return.** Index levels from Yahoo are **price
> return** — they exclude dividends — while fund NAVs are **total return**.
> Comparing them directly flatters the fund by roughly 1–1.5%/yr for Indian
> equity. Every index response carries `return_type: "price"`; say so when
> presenting a fund-vs-benchmark comparison.
## Conventions worth knowing
- **Per-fund anchor.** Every window ends at each fund's own latest NAV, not the
calendar today (the latest NAV may lag a day or two; non-trading days have no
NAV). This matches how Value Research / ET Money report.
- **Full-period start.** For trailing windows the start snaps to the latest NAV
on/before `(anchor − period)` so you capture a complete period. YTD/MTD anchor
to the first NAV on/after the calendar start; SI starts at inception.
- **Staleness.** In category queries, a fund whose last NAV lags the category's
freshest as-of date by more than `staleness_days` is flagged `stale`, kept in
the results list, and excluded from `avg_return_pct` / `avg_cagr_pct` so the
averages never blend mismatched as-of dates.
- **One connection.** The server opens a single read-only DuckDB connection at
startup and holds it for the process lifetime.
## Before trusting the numbers
Validate the start-boundary convention against a golden reference: take one real
fund, compute 1Y/3Y/5Y, and compare to its published figures for the **same
as-of date** (align the as-of first, or a date mismatch will look like a math
bug). If trailing returns come out consistently low, flip the trailing start
snap from on/before to on/after `(anchor − period)` in `_resolve_window`.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues