
👋 Welcome to Finnhub MCP Server
🎯 Project Overview
A Model Context Protocol (MCP) Server built on the official ModelContextProtocol C# SDK that exposes Finnhub's financial-data APIs to MCP-compatible clients (Claude Desktop, IDE assistants, and other agents). The server can be hosted over HTTP or STDIO and follows Clean Architecture across three projects: Server (transport + MCP surface), Server.Application (domain), and Server.Infrastructure (Finnhub HTTP client + serialization).
🚀 Key Features
- ✅ 12 tools, 3 resources, 3 prompts — a full MCP surface on the official
ModelContextProtocolandModelContextProtocol.AspNetCorepackages: aggregation-first financial-data tools, read-only reference resources, and Claude Desktop slash commands - ✅ Token-conscious by design — every tool returns a budgeted envelope with
summary/standard/fullviews; curated aggregates are the default and raw payloads are opt-in, with hard per-view token ceilings enforced by tool-invocation middleware - ✅ Intent-based tool discovery — the
search-toolsmeta-tool ranks tools with a pure-C# BM25 index (no embeddings), and acapabilitiesresource exposes the whole catalog, keeping full tool schemas off the wire until a tool is actually needed - ✅ Cross-linked
next_actions— tools suggest the next call in a workflow, so a model can chain research without guessing tool names - ✅ Response caching —
HybridCachewith per-endpoint TTL tiers (10-second quotes through 7-day exchange catalogues); identical requests short-circuit the upstream call - ✅ Input validation at every tool boundary — regex-based length and character constraints on each argument
- ✅ Resilient HTTP communication — typed
HttpClientwith hand-rolled Polly policies (Microsoft.Extensions.Http.Polly): retry with jittered backoff (timeout-aware — caller cancellations propagate immediately), timeout, and circuit breaker; premium-locked endpoints surface as typed errors and are never retried - ✅ Source-generated JSON through
System.Text.JsonJsonSerializerContextfor low-allocation, AOT-friendly (de)serialization - ✅ Strongly-typed configuration —
FinnHubOptionsbound fromappsettings.jsonwith data-annotation validation on startup - ✅ API key kept out of source — read from the
FINNHUB_API_KEYenvironment variable, or fromdotnet user-secretsin development (a legacy git-ignored.envviaDotNetEnvis still honoured) - ✅ Dual transport — HTTP (
MapMcp) for hosted scenarios and STDIO for desktop MCP clients
🚧 Available & Upcoming MCP Capabilities
✅ Currently Available
Tools (12):
search-tools— intent-based tool discovery: pass a natural-languageintent(max 200 chars) and get back the most relevant tools, ranked by a pure-C# BM25 keyword index over each tool's name, title, description, and curated example intents. Keeps full tool schemas off the wire until a tool is actually needed.summaryview omits per-tool descriptions to stay token-light;standard/fullinclude them.search-symbol— search for financial symbols by ticker, company name, ISIN, or CUSIP, optionally filtered by exchange code (limit 1–100, default 10). On a high-confidence exact match, suggestsget-quote,get-company-profile,get-news-pulse,get-financials-snapshot,get-price-summary, andget-peersas next actions.get-quote— real-time price snapshot (current, change, percent change, session high/low/open, prev close, timestamp). Cached at the 10-second Quote tier.get-company-profile— company snapshot (name, ticker, country, currency, exchange, IPO, market cap, shares outstanding, industry).view=summarydrops the cosmetic fields (logo, phone, weburl);standardandfullinclude them.get-peers— peer ticker list for a symbol, optionally grouped byindustry(default),subindustry, orsector. Summary view caps at 10 peers, standard at 25, full returns all.get-financials-snapshot— curated 10-KPI snapshot (market cap, P/E, P/B, EPS, dividend yield, 52-week high/low, 52-week return, beta, revenue per share).view=fulladds the raw upstream metric dictionary.get-price-summary— aggregated price stats over a candle range (min,max,mean,return_pct,vol,latest). Period:7d,30d(default),90d,1y.view=fulladds the raw OHLCV arrays.get-news-pulse— news pulse over the past 7 days: sentiment score (when available), top 5 headlines, article count, week-over-week delta. Gracefully degrades sentiment when the upstream/news-sentimentendpoint is premium-locked.get-calendar— parameter-dispatched calendar lookup across three feeds:kind=earnings(max 90-day window, optional symbol filter; suggestsget-financials-snapshot+get-news-pulse),kind=ipo(max 365-day window, no symbol filter; suggestsget-company-profilefor the most recent tradable IPO), andkind=economic(max 90-day window, optional ISO 3166-1 alpha-2 country filter applied server-side since the upstream doesn't accept it). Summary view caps at 10 events, standard at 25, full returns the complete window.get-insider-signal— aggregated insider-transaction signal for a symbol over the trailing 30 days (from/tooptional, max 90-day window). Returnsnet_buy_sell_30d(signed share delta),notable_names(top 5 by absolute trade volume),total_count, andlatest;view=fullincludes the full transaction array. Suggestsget-company-profileandget-quoteas next actions.get-recommendations— analyst-consensus snapshot for a symbol withchange_vs_prev(per-bucket delta + single-label sentiment shift). Returnsconsensus('Strong Buy' / 'Buy' / 'Hold' / 'Sell' / 'Strong Sell'), the 5 rating-bucket counts, andtotal;view=fullincludes the per-period history. Cached at the Profile tier — one upstream call serves both the current and previous-period values. Suggestsget-financials-snapshotandget-peersas next actions.get-exchange-symbols— aggregated, token-conscious view of the symbols listed on an exchange (exchangecode, e.g.US):total_count, atype_breakdown(count per security type), and a capped sample — not the raw list (a major exchange lists tens of thousands of symbols).summaryreturns count + breakdown only,standardadds 25 sample rows,fulladds up to 100. Cached at the 7-day Exchanges tier. Free Finnhub plans only supportUS; other exchanges are premium-gated. Suggestssearch-symbolas the next action for resolving a specific ticker.
Every tool returns the standard token-budgeted envelope with cross-linked next_actions and the most-recent observed Finnhub rate-limit headers.
Resources (3):
finnhub://resources/capabilities— the full machine-readable catalog of every registered tool (name,title,description,category,examples,premium) plustotal_count. Enumerate the whole surface in one read instead of issuing repeatedsearch-toolscalls; backed by the sameIToolRegistrythe meta-tool ranks over.finnhub://resources/exchanges— the full catalog of stock venues Finnhub supports (79 exchanges: code, name, country, MIC, timezone, market hours).urlisnullfor the few venues Finnhub lists without a reference link.finnhub://resources/api-status— latest observed Finnhub upstream quota:remaining,reset_at, and a rolling 429 count
Prompts (3):
/research-ticker {symbol}— Claude Desktop slash command that renders a deterministic research workflow: resolve the symbol (viasearch-symbol), then pullget-price-summary,get-financials-snapshot, andget-news-pulse, and synthesise a brief. A pure template — no server-side LLM calls, so the same symbol always renders byte-identical text./compare-peers {symbol}— peer-comparison workflow: find the peer set viaget-peers, fan out per peer toget-financials-snapshot, and build a side-by-side comparison. Also a pure deterministic template./news-pulse {symbol}— news-sentiment workflow: pullget-news-pulse, compare against last week, and write a sentiment narrative. Also a pure deterministic template.
📋 Planned
- Technical indicators (RSI, MACD, moving averages)
- WebSocket transport for streaming Finnhub feeds
🚀 Getting Started
📋 Prerequisites
- .NET 10 SDK
- A valid Finnhub API Key (free tier available)
🔑 Getting Your Finnhub API Key
- Visit Finnhub.io and create a free account
- Open your Dashboard and copy the API key
- Free tier provides 60 requests/minute and basic market data
📦 Installation
From source:
git clone https://github.com/SalZaki/finnhub-mcp.git
cd finnhub-mcp
dotnet restore
Install via npx (no .NET SDK required)
The server is also published to npm as finnhub-mcp. The matching native, self-contained binary for your platform (macOS/Linux/Windows · x64/arm64) is installed automatically — no .NET runtime needed. Add it to your MCP client:
{
"mcpServers": {
"finnhub": {
"command": "npx",
"args": ["-y", "finnhub-mcp", "--stdio"],
"env": { "FINNHUB_API_KEY": "your_finnhub_api_key" }
}
}
}
Or run it directly: FINNHUB_API_KEY=... npx -y finnhub-mcp --stdio.
🔐 API Key Configuration
Security note: never commit your API key. It belongs in an environment variable,
dotnet user-secrets, or a git-ignored.env— never in source orappsettings.json.
Option 1: Environment Variable
Best for CI, containers, and MCP host launchers (Claude Desktop/Code env blocks).
macOS/Linux
export FINNHUB_API_KEY="your_api_key_here"
Windows PowerShell
$env:FINNHUB_API_KEY="your_api_key_here"
Windows Command Prompt
set FINNHUB_API_KEY=your_api_key_here
Option 2: dotnet user-secrets (recommended for local development)
Stores the key under ~/.microsoft/usersecrets, outside the repo tree, so it is never at rest in a working file:
dotnet user-secrets set "FinnHub:ApiKey" "your_api_key_here" --project src/FinnHub.MCP.Server
Loaded automatically in the Development environment. A FINNHUB_API_KEY environment variable still takes precedence when set.
Option 3: .env File (legacy fallback)
Still honoured in Development via DotNetEnv, but prefer user-secrets — a .env is one git add -f away from leaking, so a pre-commit hook blocks committing it:
echo "FINNHUB_API_KEY=your_api_key_here" > .env
🚀 Running the Server
HTTP Transport (default port 8080)
dotnet run --project src/FinnHub.MCP.Server
The server binds to http://localhost:8080/ unless you override it:
dotnet run --project src/FinnHub.MCP.Server --urls http://localhost:5101
STDIO Transport
dotnet run --project src/FinnHub.MCP.Server -- --stdio
🌐 HTTP Endpoints
When running in HTTP mode the following endpoints are exposed:
| Endpoint | Purpose |
|---|---|
GET / |
Application banner — name, version, environment, status |
MapMcp() routes |
Official MCP HTTP transport endpoints |
GET /mcp/sse |
Server-Sent Events keep-alive stream |
POST /mcp/streamable |
StreamableHTTP transport stub |
GET /mcp/health |
Lightweight health probe |
/swagger |
OpenAPI UI (Development only) |
📖 Usage
The recommended way to interact with the server is through an MCP-compatible client (e.g. Claude Desktop, an MCP-aware IDE plugin, or mcp-inspector) configured to either spawn the binary over STDIO or connect to the HTTP transport.
Example STDIO entry for Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"finnhub": {
"command": "dotnet",
"args": [
"run",
"--project",
"/absolute/path/to/finnhub-mcp/src/FinnHub.MCP.Server",
"--",
"--stdio"
],
"env": {
"FINNHUB_API_KEY": "your_api_key_here"
}
}
}
}
Tool: search-tools
Parameters:
intent(string, required) — natural-language description of the task, 1–200 chars (letters/digits/space and- _ . , ' ? & / ( )). Over 200 chars or other characters are rejected as a validation error.view(string, optional) —summary(default) omits per-tool descriptions;standard/fullinclude them.
Returns the ranked matches (name, title, score, category, premium, and — outside summary — description) plus total_matches. Ranking is a pure-C# BM25 keyword index over name + title + description + curated example intents (no embeddings, no external dependency); examples are weighted above the description so curated intents drive the match. The search-tools entry itself is excluded from its own results. A drift test fails the build if a tool is registered on the server without a catalog descriptor.
Tool: search-symbol
Parameters:
query(string, required) — ticker, company name, ISIN, or CUSIP. 1–500 chars, letters/digits/space/-/_/.only.exchange(string, optional) — uppercase exchange code matching[A-Z0-9\-_]{1,50}, e.g.US,L.limit(int, optional) — 1–100, defaults to 10.view(string, optional) — response detail level. One ofsummary(default, ~500-token ceiling),standard(~2000-token ceiling),full(no ceiling).fields(string[], optional) — sparse projection over the documented response fields. Unknown field names are rejected as a validation error.
Tool: get-quote
Parameters:
symbol(string, required) — uppercase ticker, e.g.AAPL. 1–20 chars, starts with A–Z.view(string, optional) —summary/standard/fullall return the same curated snapshot fields.
Response is intentionally compact (current, change, percent_change, high, low, open, prev_close, timestamp_utc). Cached at the 10-second Quote tier.
Tool: get-company-profile
Parameters:
symbol(string, required) — uppercase ticker.view(string, optional) —summarydropslogo/phone/weburl;standardandfullinclude them.
Tool: get-peers
Parameters:
symbol(string, required) — uppercase ticker, e.g.AAPL. 1–20 chars, starts with A–Z.grouping(string, optional) —industry(default),subindustry, orsector.view(string, optional) —summary(top 10),standard(top 25),full(all).
Tool: get-financials-snapshot
Parameters:
symbol(string, required) — uppercase ticker.view(string, optional) —summary/standard(10 curated KPIs),full(KPIs + raw upstream metric dictionary).
Tool: get-price-summary
Parameters:
symbol(string, required) — uppercase ticker.period(string, optional) —7d,30d(default),90d, or1y. The1ywindow uses weekly resolution; the others use daily.view(string, optional) —summary/standard(aggregated stats),full(stats + raw OHLCV arrays).
Tool: get-news-pulse
Parameters:
symbol(string, required) — uppercase ticker.view(string, optional) —summary/standard(top 5 headlines),full(all headlines from the past 7 days).
Sentiment fields (sentiment_score, bullish_percent, bearish_percent, sentiment_source) are populated only when the upstream /news-sentiment endpoint is reachable; they fall back to null on premium-locked keys without failing the call.
Tool: get-calendar
Parameters:
kind(string, required) —earnings,ipo, oreconomic.symbol(string, optional) — uppercase ticker; applies tokind=earningsonly.country(string, optional) — ISO 3166-1 alpha-2 code; applies tokind=economiconly (filtered server-side, since the upstream does not accept it).from/to(string, optional, ISOyyyy-MM-dd) — window bounds. Max 90 days forearnings/economic, 365 foripo; sensible defaults cover the current window.view(string, optional) —summary(top 10 events),standard(top 25),full(the complete window).
Tool: get-insider-signal
Parameters:
symbol(string, required) — uppercase ticker.from/to(string, optional, ISOyyyy-MM-dd) — lookup window; defaults to the trailing 30 days, max 90.view(string, optional) —summary/standard(aggregatednet_buy_sell_30d,notable_names,total_count, andlatest),full(+ the full transaction array).
Tool: get-recommendations
Parameters:
symbol(string, required) — uppercase ticker.view(string, optional) —summary/standard(latest consensus +change_vs_prev),full(+ the per-period snapshot history).
Tool: get-exchange-symbols
Parameters:
exchange(string, required) — exchange code, 1–8 letters, e.g.US,L,T.view(string, optional) —summary(total_count+type_breakdown),standard(+ a 25-row sample),full(+ up to 100 rows).
Returns an aggregated view of a venue's symbol list — never the full list (a major exchange lists tens of thousands of symbols). Free Finnhub plans only support exchange=US; other exchanges return a typed PremiumRequired error. Suggests search-symbol for resolving a specific ticker. Cached at the 7-day Exchanges tier.
Tool response envelope
Every MCP tool returns the same envelope shape so consuming models get a predictable contract and the server can enforce per-view token budgets without forcing every tool to reimplement the same accounting.
| Field | Type | Purpose |
|---|---|---|
is_success |
bool | Operation succeeded. |
data |
T | null | Domain payload when successful. |
error_message |
string | null | Human-readable failure reason. |
error_type |
string | null | Categorised error (e.g. NotFound, BudgetExceeded). |
view |
string | Echoes the requested view. |
next_actions |
array | Server-suggested follow-up tool calls. |
explanation |
string | null | Short natural-language summary. |
approx_tokens |
int | Estimated serialized token count, set by the middleware. |
rate_limit |
object | null | Upstream Finnhub quota snapshot { remaining, reset_at } — populated after the first observed upstream response, null on cold start. |
sentiment_source |
string | null | Source label for sentiment values. |
premium |
bool | Whether the underlying upstream endpoint required a premium key. |
A response that exceeds its declared view's token ceiling is rebuilt by the tool invocation middleware as a BudgetExceeded failure envelope; retry with a broader view or a sparser fields projection.
Resource: finnhub://resources/capabilities
Returns the full tool catalog as application/json — one entry per registered tool (name, title, description, category, examples, premium) plus total_count. It reads from the same IToolRegistry the search-tools meta-tool ranks over, so the catalog and the ranker never disagree; a CapabilitiesResourceTests drift test fails the build if a registered tool is missing from the payload.
{
"tools": [
{
"name": "get-quote",
"title": "Get Quote",
"description": "…",
"category": "Pricing",
"examples": ["current stock price right now", "latest real-time quote"],
"premium": false
}
],
"total_count": 12
}
Resource: finnhub://resources/exchanges
Returns the full catalog of stock venues Finnhub supports (79 exchanges) as application/json. Finnhub exposes no /stock/exchange API endpoint — the supported-exchange list is published only as a reference document — so the catalog ships as in-process reference data captured from Finnhub's published "Supported Exchanges" sheet rather than a live upstream call.
{
"exchanges": [
{
"code": "US",
"name": "US exchanges (NYSE, Nasdaq)",
"mic": "XNYS,XASE,BATS,ARCX,XNMS,XNCM,XNGS,IEXG,XNAS, OTCM, OOTC",
"time_zone": "America/New_York",
"pre_market_hours": "04:00-09:30",
"trading_hours": "09:30-16:00",
"post_market_hours": "16:00-20:00",
"close_date": "7,0",
"country_code": "US",
"country_name": "US",
"url": "https://www.tradinghours.com/exchanges/nyse"
}
],
"total_count": 79,
"has_results": true
}
url is null for the few venues Finnhub lists without a reference link.
Resource: finnhub://resources/api-status
Returns the most-recent observed Finnhub upstream quota state as application/json:
{
"remaining": 42,
"reset_at": "2026-12-31T23:59:59Z",
"recent_throttled_count": 0
}
The remaining and reset_at fields are null before the server has made any upstream call; the recent_throttled_count resets to zero whenever the quota window rolls over. Clients can poll this resource to monitor headroom without invoking a tool.
Prompts (Claude Desktop slash commands)
The server exposes three MCP prompts — deterministic, templated research workflows surfaced as slash commands in Claude Desktop. Each takes a single symbol argument (1–32 characters: letters, digits, dots, or dashes; validated and uppercased before render) and produces byte-identical text for a given symbol: the render is a pure template with no server-side model calls.
| Slash command | Workflow it scripts |
|---|---|
/research-ticker {symbol} |
Resolve the symbol (search-symbol), then get-price-summary → get-financials-snapshot → get-news-pulse, and synthesise a brief. |
/compare-peers {symbol} |
Find the peer set (get-peers), fan out per peer to get-financials-snapshot, and build a side-by-side comparison. |
/news-pulse {symbol} |
Pull get-news-pulse, compare against last week, and write a sentiment narrative. |
The prompts only template the instructions — the client's model executes the tool calls, so the workflow honours the same token budgets, caching, and premium handling as a hand-typed sequence.
Response caching
Every Application service is fronted by Microsoft.Extensions.Caching.Hybrid.HybridCache with per-endpoint TTL tiers. Identical requests within the tier's TTL short-circuit the upstream Finnhub call.
Tiers (defaults shown):
| Tier | Default TTL | Used for |
|---|---|---|
Quote |
10s | Live market data |
News |
60s | News articles, sentiment, symbol search |
Financials |
1h | Reported financials, KPI snapshots |
Profile |
24h | Company profiles, peer lists |
Exchanges |
7d | Stock exchange catalogues |
TTLs are tunable in appsettings.json under the Cache section. Bad values (zero or out-of-range) fail startup validation. Cache keys are namespaced with a tenant=shared prefix today; a future BYOK milestone partitions per user without a key-shape migration. See .planning/specs/01-product-surface.md §3 P2 for the full design.
⚙️ Configuration
Configuration is loaded from appsettings.json, an optional environment-specific appsettings.{Environment}.json, dotnet user-secrets (in Development), environment variables, and command-line arguments — in that order. The FINNHUB_API_KEY environment variable, if present, overrides FinnHub:ApiKey.
FinnHubOptions (bound from the FinnHub section) drives the API key, base URL, and per-endpoint settings. CORS allows any origin in Development; in other environments it reads from the AllowedOrigins array.
🧪 Testing
The solution ships with three xUnit unit-test projects, mocked using NSubstitute and measured with coverlet:
# Run the full test suite
dotnet test
# Run a single project
dotnet test tests/FinnHub.MCP.Server.Application.Tests.Unit
dotnet test tests/FinnHub.MCP.Server.Infrastructure.Tests.Unit
dotnet test tests/FinnHub.MCP.Server.Tests.Unit
# With coverage (uses coverlet.runsettings at the repo root)
dotnet test --settings coverlet.runsettings
A fourth project, FinnHub.MCP.Server.Tests.LiveSmoke, boots the server in-process and walks every tool against real Finnhub to catch upstream shape drift and URL-resolution regressions that mocked tests can't. It is tagged [Trait("Category", "LiveSmoke")] and excluded from dotnet test by default (--filter "Category!=LiveSmoke"), running on a daily schedule and on demand so PR builds never burn the Finnhub quota.
🔧 Development
Build
dotnet build
Hot reload
dotnet watch --project src/FinnHub.MCP.Server
Format & analyzers
dotnet format
TreatWarningsAsErrors is enabled in Directory.Build.props, so any analyzer or compiler warning will fail the build.
🏗️ Project Structure
src/
├── FinnHub.MCP.Server/ # ASP.NET Core host, MCP transport wiring, Tools, Resources
├── FinnHub.MCP.Server.Application/ # Domain models, queries, services, exceptions
└── FinnHub.MCP.Server.Infrastructure/ # Finnhub HTTP client, DTOs, JSON context, DI registration
tests/
├── FinnHub.MCP.Server.Application.Tests.Unit/
├── FinnHub.MCP.Server.Infrastructure.Tests.Unit/
├── FinnHub.MCP.Server.Tests.Unit/
└── FinnHub.MCP.Server.Tests.LiveSmoke/ # gated, real-Finnhub end-to-end smoke (daily / on-demand)
🛠️ Tech Stack
- Framework: .NET 10 with ASP.NET Core
- MCP SDK:
ModelContextProtocolandModelContextProtocol.AspNetCore - Resilience: Polly via
Microsoft.Extensions.Http.Polly— hand-rolled retry + circuit-breaker policies - Serialization:
System.Text.Jsonwith source-generatedJsonSerializerContext - Testing: xUnit, NSubstitute, coverlet
- Configuration: DotNetEnv
- OpenAPI:
Microsoft.AspNetCore.OpenApi+Swashbuckle.AspNetCore - CI/CD: GitHub Actions (third-party actions SHA-pinned, dependabot-managed, NuGet-cached) with release-please for automated versioning
🤝 Contributing
- Fork the repository
- Create a feature branch (
git checkout -b feat/short-description) - Make your changes and add tests
- Ensure
dotnet buildanddotnet testboth pass - Use Conventional Commits — release-please depends on them
- Submit a pull request
📄 License
Licensed under the MIT License — see LICENSE for details.
🙏 Acknowledgments
- Finnhub.io for the financial-data APIs
- The Model Context Protocol team and the official C# SDK
- The .NET community for the surrounding ecosystem
📞 Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Finnhub API: Finnhub Documentation
No comments yet
Be the first to share your take.