bd-finance-mcp
# bd-finance-mcp
Bangladesh's financial markets, available to your AI assistant. Both stock exchanges, every
listed share, and what the taka is worth today.
An [MCP](https://modelcontextprotocol.io/) server built with [FastMCP](https://github.com/PrefectHQ/fastmcp).
> ⚠️ **Not investment advice.** This reports publicly published prices. It does not analyse,
> recommend, or predict. Figures are last-traded values and may lag the live market.
> **Unofficial.** Not affiliated with DSE, CSE, or Bangladesh Bank.
## Tools
| Tool | What it does |
|---|---|
| `compare_exchanges(code)` | The same share priced on **both** exchanges, with the spread between them. |
| `market_summary(exchange)` | Breadth — how many shares rose, fell, held flat or never traded. |
| `top_movers(direction, exchange, limit)` | `gainers`, `losers`, `volume` or `value`. |
| `get_stock(codes, exchange)` | Prices for specific trading codes. |
| `search_stock(query, exchange)` | Find codes by substring, e.g. `"BANK"`. |
| `sector_performance(sector)` | How a whole sector traded — did banks rise while textiles fell? |
| `list_sectors()` | The 22 DSE sectors you can ask about. |
| `company_profile(code)` | Sector, market cap, P/E ratio, paid-up capital, shares outstanding. |
| `exchange_rate(currencies)` | Taka per USD, EUR, GBP, SAR, AED, MYR and 160 more. |
| `finance_news(keyword, sources, business_only)` | Latest business and financial headlines. |
| `list_news_sources()` | Which papers are covered, and which could not be. |
`company_profile` turns a bare price into something you can reason about — GP trades at a P/E of
11.44 in Telecommunication with a 326,907mn taka market cap, BRAC Bank at 6.14 in Banking. Prices
alone don't tell you that.
`compare_exchanges` is the one you can't easily do yourself — most Bangladeshi companies list on
both markets, and the prices genuinely differ. GP was trading at 240.60 on Dhaka and 241.50 on
Chittagong while this was being written.
`sector_performance` answers the question a price list can't: *how did banks do today?* It
aggregates every share in a sector — advancers, decliners, average move, best and worst — from
one query. Partial names work, so "pharma" finds "Pharmaceuticals & Chemicals".
Prices tell you a share moved; `finance_news` tells you why.
**Every market response carries a `market` block** saying whether DSE is currently trading
(Sunday–Thursday, 10:00–14:30 Asia/Dhaka) and whether the figures are live or the last completed
session. Outside session hours, "today's gainers" means the previous close — the response says so
rather than leaving you to assume.
## Sources
| Source | Provides |
|---|---|
| [dsebd.org](https://www.dsebd.org) | Dhaka Stock Exchange — ~395 instruments, company fundamentals |
| [cse.com.bd](https://www.cse.com.bd) | Chittagong Stock Exchange — ~387 instruments |
| [open.er-api.com](https://open.er-api.com) | Currency reference rates, 166 currencies |
| dsebd.org sector pages | 22 sectors and their constituents |
| The Daily Star — Business | Bangladeshi business news |
| The Business Standard — Economy | Bangladeshi business news |
| Dhaka Tribune | General newsroom (see note below) |
| Financial Times — Companies | International business news |
**Dhaka Tribune publishes no business-only feed**, so its articles are general newsroom output.
`finance_news` leaves it out by default (`business_only=True`) rather than passing student
politics off as market coverage — name it in `sources`, or set `business_only=False`, to include it.
**Bonik Barta is not available.** It is a single-page app: every path, including `/api/v1/`,
returns the same HTML shell with HTTP 200. There is no feed or API behind it. A status code alone
is not proof a source works.
**New Age is not available.** No working RSS feed exists at any conventional path.
**Financial Times links are paywalled.** Headlines and summaries come through the public feed;
the articles themselves require a subscription.
No API key, login, or paid data feed.
**Not included: Bangladesh Bank.** Its econdata pages sit behind a CAPTCHA challenge. That is an
explicit request not to automate, and this project respects it rather than working around it. Use
`exchange_rate` for reference rates instead — but note those are mid-market, not BB's official
rate, and not what a bank will actually pay you.
**Not included: gold prices.** BAJUS renders them client-side; nothing is in the HTML to parse.
**Not included: the DSEX index.** It appears only in DSE's navigation menus — the live value is
injected by JavaScript, so there is nothing to read from the served HTML. `market_summary` gives
you breadth and turnover instead, which describes the session without pretending to quote an
index it cannot see.
## Install
Requires [uv](https://docs.astral.sh/uv/) and Python 3.12+.
```bash
git clone https://github.com/Claudefarid/bd-finance-mcp.git
cd bd-finance-mcp
uv sync
uv run fastmcp install claude-code server.py:mcp
```
Restart your client, then ask *"Compare GP's price on both Bangladeshi exchanges"* or
*"What's the taka doing against the riyal?"*
Verify with `uv run python test_server.py`.
## Six things that will bite you if you build this yourself
Each of these produces **wrong answers rather than errors** — the dangerous kind of bug.
**1. The two exchanges disagree about untraded shares.**
DSE reports a share that did not trade with price `0`. CSE carries yesterday's price forward.
Compute percent change naively and DSE hands you a list of −100% "crashes" that are really just
shares nobody bought. Trade count is the only signal that works on both, so it decides whether a
percentage is meaningful here.
This matters more on CSE than you would guess: **187 of its 387 listed shares did not trade** on
the day this was written. Nearly half the market. Get this wrong and CSE data is worthless.
**2. `dsebd.org` serves an incomplete TLS certificate chain.**
Its leaf certificate is signed by a Sectigo intermediate the server never sends. Browsers and
curl recover by fetching it via AIA; Python's `certifi` bundle cannot, so `httpx` fails with
`CERTIFICATE_VERIFY_FAILED`.
**The fix is not `verify=False`** — that disables verification altogether, which is a bad trade
anywhere and a worse one for financial data. Verify against the OS trust store instead:
```python
import ssl, truststore
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
httpx.AsyncClient(verify=ctx)
```
**3. DSE's P/E table is one column per trading day, and the newest is last.**
The ratio table sits under a `Particulars` header whose columns are dates. Read the first column —
the obvious choice — and you report a P/E from a week ago as today's. `company_profile` takes the
last populated column and returns the date alongside it, so the figure is always checkable.
There is a second trap in the same table: the page carries several `Particulars` headers, and the
first one reads "Unaudited / Audited" rather than dates. Matching on width picks the right one.
A missing P/E is not a parsing failure, either. DSE prints `-` across the row for companies
without meaningful earnings — BEXIMCO, for one — so `None` there is the honest answer.
**4. The Daily Star's feed puts HTML inside its `<title>` tags.**
Its headlines arrive wrapped in an unescaped anchor:
```xml
<title><a href="/business/news/...">7,896 customers withdraw Tk 327 crore</a></title>
```
ElementTree parses that anchor as a child element, so `findtext("title")` returns an empty
string — not an error, just nothing. Every Daily Star headline came back blank, and because an
empty headline matches no keyword, the paper looked like it was ignoring every topic when it was
simply never being read. `"".join(element.itertext())` collects descendant text and fixes it.
**5. Substring matching puts the IT sector inside "Financial Institutions".**
The obvious way to resolve a sector name is `query in name.lower()`. Ask for `"IT"` and you match
both `IT Sector` and `Financial Inst**it**utions` — so the most natural query for the sector fails as
ambiguous. Matching runs in tiers instead: exact, then prefix, then word-boundary, then substring,
taking the first tier that resolves to one sector.
**6. Mid-market rates are not remittance rates.**
`exchange_rate` returns reference rates. Banks and exchange houses apply their own spread, so
what actually lands in a recipient's account is lower. The tool says so in its own description,
so a model relying on it does not quietly present these as the rate you'd receive.
## Please be considerate
Each call is a single page fetch, and requests identify themselves by User-Agent. Don't poll in
a tight loop.
## License
MIT — see [LICENSE](LICENSE). Covers this code only, not the exchanges' data.
TDQS
Scored across 11 tools
Each tool targets a distinct slice of the market: movers, breadth, quotes, sectors, news, FX, and cross-exchange comparison. The only mild ambiguity is between get_stock and compare_exchanges, but the descriptions explicitly point from one to the other.
Names are all snake_case, but the set mixes verb-led actions (search_stock, list_sectors, get_stock, compare_exchanges) with noun-only snapshots (top_movers, market_summary, exchange_rate, company_profile, finance_news, sector_performance). There is no single predictable pattern, though each name is still readable.
11 tools is well within the ideal range, and each tool covers a meaningful part of the domain: quotes, movers, sectors, fundamentals, news, FX, and exchange comparison. No obvious redundancy that would justify trimming.
The server covers the main read-only market data needs: current prices, movers, breadth, sector aggregates, company fundamentals, news, FX, and dual-exchange quotes. Missing historical price/performance and index-level data are notable but workable gaps for an agent doing day-of analysis.