EODHD Guard

Official registry Other

EODHD market data that catches silent failures: frozen codes, NA quotes, empty answers.

Install

Add this to your client's MCP configuration (Claude Desktop: claude_desktop_config.json; Cursor: .cursor/mcp.json; VS Code and others use the same "mcpServers" shape).

PYPI package eodhd-guard-mcp
Claude Code
claude mcp add eodhd-guard -e 'EODHD_API_KEY=<your eodhd_api_key>' -e 'EODHD_GUARD_DAILY_BUDGET=1000' -- uvx eodhd-guard-mcp==0.1.0
JSON configuration (Claude Desktop, Cursor, VS Code)
{
    "mcpServers": {
        "eodhd-guard": {
            "command": "uvx",
            "args": [
                "eodhd-guard-mcp==0.1.0"
            ],
            "env": {
                "EODHD_API_KEY": "<your eodhd_api_key>",
                "EODHD_GUARD_DAILY_BUDGET": "1000"
            }
        }
    }
}

Environment variables

  • EODHD_API_KEY required secret Your EODHD API key. You need your own EODHD subscription.
  • EODHD_GUARD_DAILY_BUDGET Most EODHD API calls this server may spend per GMT day; 0 means no limit.

Documentation

From the project's README on GitHub, refreshed weekly. Links open on GitHub.

Contents
  1. eodhd-guard-mcp
  2. Why
  3. Requirements
  4. Data licence notice
  5. Unofficial
  6. Install
  7. Configure
  8. Environment variables
  9. What a response looks like
  10. Tools
  11. Warnings and errors
  12. Costs, budget and usage
  13. Cache and state files
  14. Security
  15. Data caveats
  16. Compared with EODHD's official MCP server
  17. Development
  18. Licence

eodhd-guard-mcp

A guard-first MCP server for the EODHD market-data API: it catches silent failures, says what each price is, and counts what every call costs.

Status: alpha (0.1.x). Tool names, parameters and output fields can still change before 1.0.

Unofficial. This project is not affiliated with or endorsed by EODHD or Unicorn Data Services. You need your own EODHD subscription, and EODHD's data licence applies to everything you fetch. See the data licence notice.

Why

EODHD is a broad, inexpensive market-data API. An evaluation in October 2026 (about 930 calls) found its data sound for the US, London and Swiss markets, and good as a cross-check for India. It also found failures that an AI assistant cannot see, because the API reports them as success:

  • Frozen codes. A delisted or renamed code keeps answering HTTP 200 with frozen data. Roche's Swiss line changed from ROG.SW to ROP.SW in March 2026, and the old code kept answering.
  • Empty and placeholder answers. A real-time quote for an unknown code is HTTP 200 with every field set to "NA". Tokyo and the UAE have no prices and answer with [] or a bare 404.
  • Ignored parameters. A bad from date is ignored and the whole history comes back. An unknown period falls back to daily. A symbol without an exchange suffix silently means the US.
  • Prices that are not what they look like. close is not adjusted for splits or dividends, yet it is back-adjusted for some spin-offs and demergers. adjusted_close is the column to use for returns.
  • Lateness. Quotes are 15 to 20 minutes delayed. Intraday bars for the current session are not available. End-of-day bars appear anywhere from 10 minutes (US) to 4.5 hours (India) after the close.
  • Cost. Every endpoint has a price in API calls (end-of-day 1, intraday 5, fundamentals 10), so an unattended agent can spend a day's allowance in minutes.

This server has 14 tools, and every one has guards. It validates arguments before sending anything, turns empty and placeholder answers into errors or explicit warnings, says whether each price is raw or adjusted and how old it is, states the cost of every response, counts every call in a local ledger, and stops at a daily budget. Each caveat is explained in docs/caveats.md.

Requirements

  • Python 3.10 or later.
  • Your own EODHD subscription and API key. This project does not include or resell data.
  • An MCP client that can start a stdio server or connect to a streamable-HTTP one, such as Claude Code or Claude Desktop.
EODHD plan for each tool

EODHD refuses requests that your plan does not include (HTTP 403). The server reports that as PLAN_ERROR. For intraday bars, fundamentals, the three calendars and dividend and split history, the message names the plans that include the endpoint. For the other endpoints it says that a higher plan may include it.

ToolsEODHD plan needed
get_eod_prices, get_dividends, get_splits, search_symbols, list_exchanges, list_exchange_symbols, check_symbol"EOD Historical Data" or higher
get_intraday_prices"EOD+Intraday" or "All-in-One"
get_fundamentals, get_earnings_calendar, get_dividend_calendar, get_split_calendar"Fundamentals Data Feed" or "All-in-One"
get_quoteLive (delayed) prices: check EODHD's plan table for your plan
get_usageAny plan; the account endpoint is free

check_symbol also needs the plan that covers get_quote when you pass include_quote. The free EODHD plan allows 20 calls a day and one year of end-of-day history; if you use it, set EODHD_GUARD_DAILY_BUDGET=20. EODHD changes its plans from time to time, so check eodhd.com for the current table.

Data licence notice

EODHD's data is licensed for personal, non-commercial use, and EODHD's terms do not allow redistributing it. That was the position at the time of the October 2026 evaluation; read the current terms on eodhd.com before you use output for anything beyond your own analysis.

  • This project ships no EODHD data. Its tests use synthetic fixtures.
  • You are responsible for complying with EODHD's terms for everything you fetch, store and share.
  • The local cache holds EODHD responses. Do not share it, sync it to a shared folder or commit it. See Cache and state files.
  • Nothing here is legal advice.

Unofficial

eodhd-guard-mcp is an independent project. It is not affiliated with, endorsed by or supported by EODHD or Unicorn Data Services. "EODHD" and "EOD Historical Data" are names of their respective owners, used here only to say which API the server talks to.

Install

The package is on PyPI. Run it without installing, with uv:

uvx eodhd-guard-mcp --version

Or install it, preferably into an isolated environment:

pipx install eodhd-guard-mcp
# or, inside a virtual environment
pip install eodhd-guard-mcp

It is also listed in the official MCP registry as io.github.techfreakworm/eodhd-guard-mcp.

The latest unreleased code installs from GitHub:

pip install git+https://github.com/techfreakworm/eodhd-guard-mcp

Or from a clone:

git clone https://github.com/techfreakworm/eodhd-guard-mcp.git
cd eodhd-guard-mcp
pip install -e .

Check the install and your configuration:

eodhd-guard-mcp --version
EODHD_API_KEY_FILE="$HOME/.config/eodhd-guard-mcp/api_key" eodhd-guard-mcp --check

--check prints the configuration to stderr, with the key shown only as set or not set, together with the state directory and the budget. Add --online to make one free call to EODHD's account endpoint and print only its usage counters. The exit code is non-zero when something is wrong. See Command line.

Configure

The server needs your API key. Pass it as EODHD_API_KEY_FILE, the path of a file that holds only the key, or as EODHD_API_KEY. Prefer the file. An inline key lands in your shell history and in your MCP client's configuration, in plain text.

To create the key file without putting the key in your shell history:

mkdir -p ~/.config/eodhd-guard-mcp
(umask 077; touch ~/.config/eodhd-guard-mcp/api_key)
${EDITOR:-nano} ~/.config/eodhd-guard-mcp/api_key
Claude Code
claude mcp add eodhd-guard --env EODHD_API_KEY_FILE="$HOME/.config/eodhd-guard-mcp/api_key" -- eodhd-guard-mcp

With the key inline, which is easier but leaves it in your shell history and in Claude Code's configuration file:

claude mcp add eodhd-guard --env EODHD_API_KEY=... -- eodhd-guard-mcp

examples/claude_code.md has the full walk-through, including uvx, user scope and a lower budget.

Claude Desktop

Add the server to claude_desktop_config.json and restart Claude Desktop. The file is at ~/Library/Application Support/Claude/ on macOS and %APPDATA%\Claude\ on Windows.

{
  "mcpServers": {
    "eodhd-guard": {
      "command": "eodhd-guard-mcp",
      "args": [],
      "env": {
        "EODHD_API_KEY_FILE": "/Users/you/.config/eodhd-guard-mcp/api_key"
      }
    }
  }
}

Claude Desktop does not always see the PATH of your shell. If it cannot find the command, put the absolute path from which eodhd-guard-mcp (macOS and Linux) or where eodhd-guard-mcp (Windows) in command. On Windows, write backslashes twice inside the JSON. Or let uvx fetch and run it:

{
  "mcpServers": {
    "eodhd-guard": {
      "command": "uvx",
      "args": ["eodhd-guard-mcp"],
      "env": {
        "EODHD_API_KEY_FILE": "/Users/you/.config/eodhd-guard-mcp/api_key"
      }
    }
  }
}

The same file is in examples/claude_desktop_config.json.

Any other stdio client

Start eodhd-guard-mcp with no arguments and set EODHD_API_KEY_FILE in its environment. python -m eodhd_guard_mcp does the same. Logs go to stderr, because stdout carries the protocol.

Streamable HTTP
eodhd-guard-mcp --transport streamable-http --host 127.0.0.1 --port 8000

The server answers at http://127.0.0.1:8000/mcp. This transport has no authentication. Anyone who can reach the port can spend your EODHD calls, so keep the host on 127.0.0.1. The server prints a warning to stderr to remind you.

Command line
OptionMeaning
`--transport stdio\streamable-http`How to serve MCP. The default is stdio.
--host, --portFor streamable HTTP. Defaults: 127.0.0.1 and 8000.
--checkPrint the configuration, the state directory and the budget, and exit.
--check --onlineAlso make the free account call and print its counters.
--versionPrint the version.

--check writes a JSON report to stderr, because stdout carries the protocol on stdio. It holds the version, api_key (set or NOT SET), the base URL, the state directory, the daily budget (a number, or unlimited), the cache setting, the stale-session threshold and the coverage switch. With --online it adds account (EODHD's counters) or account_error. The exit code is 0 when all is well, 1 when no key is set or the online call fails, and 2 when a setting is invalid.

Environment variables

VariableMeaningDefault
EODHD_API_KEYAPI keynone
EODHD_API_KEY_FILEFile holding the key (used when EODHD_API_KEY is unset)none
EODHD_GUARD_DAILY_BUDGETCost units per GMT day, 0 = unlimited1000
EODHD_GUARD_STATE_DIRLedger and cache folder$XDG_STATE_HOME/eodhd-guard-mcp, else ~/.local/state/eodhd-guard-mcp; %LOCALAPPDATA%\eodhd-guard-mcp on Windows
EODHD_GUARD_CACHEon or off (also 1/0, true/false, yes/no)on
EODHD_GUARD_STALE_SESSIONSSessions behind before data counts as stale (STALE_DATA, STALE_QUOTE, POSSIBLY_INACTIVE and the check_symbol verdict); an integer, 1 or more3
EODHD_GUARD_TIMEOUTRequest timeout in seconds, above 030
EODHD_GUARD_MAX_RETRIESRetries for 429, 5xx and network errors; an integer, 0 to 52
EODHD_GUARD_ALLOW_UNCOVEREDSkip the coverage pre-check for Tokyo and the UAEoff
EODHD_GUARD_BASE_URLAPI base, for tests and proxies; see belowhttps://eodhd.com/api
EODHD_GUARD_LOG_LEVELDEBUG, INFO, WARNING, ERROR or CRITICAL; logs go to stderrWARNING

An invalid value stops the server at start-up with a CONFIG_ERROR message that names the variable and the values it accepts. A key file that cannot be read, or is empty, does the same. A key that is simply not set does not stop the server: listing tools works, and each tool call fails with CONFIG_ERROR. A variable that is set but blank counts as not set. The key and the content of a key file are stripped of surrounding whitespace, and a leading ~ in EODHD_API_KEY_FILE and EODHD_GUARD_STATE_DIR is expanded.

EODHD_GUARD_BASE_URL must be an https URL, or an http URL whose host is localhost, 127.0.0.1 or ::1, with no credentials, query or fragment. A trailing slash is removed. The key goes to this address, so the server refuses anything else. Redirects are never followed.

What a response looks like

Every tool returns the same envelope:

  • data: the result.
  • warnings: a list of {code, message, details}. Show every warning to the user. They are the point of this server.
  • meta: source, endpoints (the EODHD endpoints used), cost (API calls charged by this response), cached (true when every EODHD answer came from the cache, and also when no call was made), fetched_at (when EODHD produced the data; the oldest time for a result built from several calls; null when no call was made) and notes (how to read the data).

The server also gives the model short instructions when it connects: symbols are CODE.EXCHANGE and a bare code is refused; pass every warning on to the user; meta.cost is the price in API calls and the daily budget is enforced, so check get_usage before bulk work; use adjusted_close for returns; quotes are delayed 15 to 20 minutes and intraday bars appear only after the session closes; EODHD has no prices for Tokyo or the UAE, although Tokyo earnings dates are in get_earnings_calendar; and run check_symbol when data looks old or a code returns nothing.

This is the shape of an end-of-day answer for the call {"symbol": "ACME.US", "start": "2026-09-04", "end": "2026-09-08"}. The company is fictional and the numbers are invented. The range spans a US market holiday, and EODHD sent a filler bar for it.

{
  "data": {
    "symbol": "ACME.US",
    "period": "d",
    "price_basis": "both",
    "start": "2026-09-04",
    "end": "2026-09-08",
    "first_date": "2026-09-04",
    "last_date": "2026-09-08",
    "count": 2,
    "bars": [
      {"date": "2026-09-04", "open": 10.0, "high": 11.0, "low": 9.5, "close": 10.5, "adjusted_close": 10.4, "volume": 1000},
      {"date": "2026-09-08", "open": 10.5, "high": 11.5, "low": 10.0, "close": 11.0, "adjusted_close": 10.9, "volume": 1200}
    ]
  },
  "warnings": [
    {
      "code": "FILLER_BARS_REMOVED",
      "message": "Removed 1 holiday filler bar(s) (zero volume, open = high = low = close): 2026-09-07. Pass include_filler_bars=true to keep them.",
      "details": {"removed": 1, "dates": ["2026-09-07"]}
    }
  ],
  "meta": {
    "source": "EODHD",
    "endpoints": ["eod"],
    "cost": 1,
    "cached": false,
    "fetched_at": "2026-09-09T21:00:00Z",
    "notes": ["open/high/low/close are as EODHD reports them: not adjusted for splits or dividends, but sometimes back-adjusted for spin-offs and demergers, so they may differ from the traded price. adjusted_close is adjusted for splits and dividends; use it for returns."]
  }
}

A failed call comes back as an MCP error result. The MCP SDK puts Error executing tool <name>: in front, then the text starts with the error code, followed by the message and, where one helps, a hint:

Error executing tool get_eod_prices: INVALID_PARAMS: symbol: received 'AAPL'; it has no exchange suffix, so EODHD would silently use the US exchange, which can return the wrong company or nothing useful. Accepted: CODE.EXCHANGE, for example AAPL.US, VOD.LSE, NESN.SW, RELIANCE.NSE. Hint: Add the exchange after a dot (.US for a US stock), or find the code with search_symbols.

Tools

All tools are read-only. Costs are in EODHD API calls. A cached answer costs 0.

ToolReturnsCostKey guard
get_eod_pricesDaily, weekly or monthly bars, raw and adjusted1Flags frozen codes, drops holiday filler bars, labels the price basis, warns about spin-off back-adjustment
get_intraday_prices1-minute, 5-minute or 1-hour bars5Says when same-day bars cannot exist yet
get_quoteDelayed quotes for up to 20 symbols1 per symbol sentTurns "NA" answers into no_quote; always flags the delay
get_fundamentalsTrimmed sections and statements10 for the first read, then free from cache for 24 hoursCurrency, filing-date, dividend-yield and EPS-basis warnings; output capped at about 150 KB
get_earnings_calendarEarnings dates in a window1One whole-market request, filtered locally; timing flags marked unreliable
get_dividendsDividend history1Currency check; split-adjusted and as-declared values labelled
get_dividend_calendarSymbols with a dividend dated on one day1Notes that the calendar has no amounts
get_splitsSplit history1Flags ratios that look like spin-offs or rights adjustments
get_split_calendarSplits in a window1Filtered locally by symbols or exchange
list_exchangesExchanges with codes, countries and currencies1Notes Tokyo and the UAE on their rows, and always warns that being listed is not proof of coverage
list_exchange_symbolsSymbols on one exchange, active or delisted1Filters and pages locally; an empty list is an error
search_symbolsSymbols matching a name or code1Marks lines that may be inactive
check_symbolA verdict on one symbol, with likely successors1 to 5 (0 for an uncovered market)Looks for a successor when a code looks dead
get_usageLocal ledger, budget and account counters0Never returns account holder details

Each section below gives the signature with its defaults and bounds, the cost, what comes back and an example of the arguments of a call. Symbols are always CODE.EXCHANGE, for example AAPL.US, VOD.LSE, NESN.SW or RELIANCE.NSE; US share classes use a hyphen, as in BRK-B.US. Dates are written YYYY-MM-DD, and a number is never read as a date. A value out of range is refused, never silently cut.

get_eod_prices

get_eod_prices(symbol, start=None, end=None, period="d", price_basis="both", include_filler_bars=False, max_rows=500)

End-of-day bars for one symbol. Cost: 1. Cached for 6 hours when end is within a day of today, otherwise 24 hours.

  • end defaults to today (UTC) and start to 30 days before end. An end after today is clamped to today (RANGE_CLAMPED). A start after today or after end is an error.
  • period is d, w or m. A weekly or monthly bar is kept when its period overlaps the range, because EODHD may label it with the first or the last day of the period.
  • max_rows is 1 to 5000, and the most recent rows are kept.
  • price_basis is both (close and adjusted_close), raw or adjusted. Every answer says in meta.notes which is which. With adjusted, close is adjusted_close and open, high and low are scaled by adjusted_close / close; a bar without adjusted_close gets nulls, and a note counts them.
  • Guards:
    • Frozen or renamed codes (STALE_DATA): for daily bars whose range ends within 7 days of today, the newest bar that is not a filler bar is EODHD_GUARD_STALE_SESSIONS or more sessions behind the latest session expected.
    • Empty answers for a range that has trading days (NO_DATA). The message states what the call cost. A range of weekend days only is a normal empty result with EMPTY_RESULT.
    • Holiday filler bars (FILLER_BARS_REMOVED), and rows outside the range (RANGE_TRIMMED).
    • Known spin-offs (SPINOFF_ADJUSTED_CLOSE), judged on the first bar returned, so a max_rows cut is taken into account.
    • Pence quoting on London lines (CURRENCY_NOTE).
    • An unknown code is NOT_FOUND, with a hint to use search_symbols, or list_exchange_symbols with delisted=true. A code such as BRK.B gets a hint that US share classes use a hyphen.
  • Data: {symbol, period, price_basis, start, end, first_date, last_date, count, bars}. start and end are the range actually used, and first_date and last_date are null when there are no bars.
{"symbol": "AAPL.US", "start": "2026-10-01", "end": "2026-10-30", "price_basis": "adjusted"}
get_intraday_prices

get_intraday_prices(symbol, interval="5m", start=None, end=None, max_rows=500)

Intraday bars. Cost: 5. Cached for 15 minutes when the window reaches today's trading session on the symbol's exchange, otherwise 24 hours.

  • interval is 1m, 5m or 1h. start and end take YYYY-MM-DD or an ISO 8601 timestamp; a timestamp without an offset is UTC.
  • A date-only start means 00:00 UTC. A date-only end means the end of that UTC day (23:59:59, inclusive), so end="2026-10-30" includes the 30th. A timestamp end is used exactly.
  • end defaults to now, rounded up to the next quarter hour, so that repeated calls share a cache entry. An end in the future is clamped to that and announced with RANGE_CLAMPED; a date-only end of today is clamped without a warning, because asking for data through today is fair.
  • start defaults to one day before end for 1m, three days for 5m and 14 days for 1h; with a date-only end the default window is whole UTC days. The longest span is 120, 600 and 7200 days respectively. A start in the future, or not before the end, is an error.
  • max_rows is 1 to 5000, and the most recent rows are kept (TRUNCATED).
  • Bars: {datetime_utc, open, high, low, close, volume}. volume can be null. Rows that EODHD sends without prices, for intervals with no trade, are skipped, and a note says how many.
  • Data: {symbol, interval, start, end, first_datetime_utc, last_datetime_utc, count, bars}. start and end are the window actually used.
  • Guards:
    • INTRADAY_NOT_SAME_DAY when the window reaches today's session, the session is open or has closed, and EODHD has no bar dated today yet.
    • A window with no bars at all is not an error: it returns an empty list and NO_INTRADAY_DATA.
    • Bars outside the window are dropped (RANGE_TRIMMED).
  • Needs a plan with intraday data.
{"symbol": "AAPL.US", "interval": "5m", "start": "2026-10-28T13:30:00Z", "end": "2026-10-28T20:00:00Z"}
get_quote

get_quote(symbols)

Delayed quotes. Cost: 1 per symbol sent, at most 20 symbols per call. Cached for 60 seconds.

  • symbols is a list or a comma-separated string. A repeated symbol is sent once, and a symbol on an uncovered market is never sent, so it costs nothing.
  • data is a plain list with one entry per requested symbol, in request order: {symbol, status: "ok", price, open, high, low, previous_close, change, change_pct, volume, as_of, age_seconds, delayed: true}, or {symbol, status: "no_quote" | "missing" | "not_covered", message}. price is the close field of EODHD's answer and as_of is the quote's own timestamp in UTC. missing means EODHD's answer left the symbol out.
  • Guards: "NA" fields become no_quote with NO_QUOTE; STALE_QUOTE when a quote's date is EODHD_GUARD_STALE_SESSIONS or more sessions behind; an uncovered symbol gets not_covered and a NOT_COVERED warning; if no symbol has a quote, the call fails with NO_DATA, and its message says what the call cost. DELAYED_QUOTE is always present, and CURRENCY_NOTE marks London lines.
{"symbols": ["AAPL.US", "VOD.LSE", "RELIANCE.NSE"]}
get_fundamentals

get_fundamentals(symbol, sections=None, statements=None, period="quarterly", periods=4)

Company fundamentals, trimmed. Cost: 10 for the first request for a symbol. One request fetches the whole document, which is cached for 24 hours, so every later section or statement read for that symbol is free in that time.

  • Default sections: for stocks General, Highlights, Valuation, SharesStats, SplitsDividends and Earnings; for ETFs General, Technicals and ETF_Data; for funds General and MutualFund_Data.
  • sections is a list or a comma-separated string of names from General, Highlights, Valuation, SharesStats, Technicals, SplitsDividends, AnalystRatings, Holders, InsiderTransactions, ESGScores, outstandingShares, Earnings, ShortPositionDisclosure, ETF_Data and MutualFund_Data. Case, spaces, underscores and hyphens in a name do not matter. sections=[] means no sections, for when only statements are wanted. Statements are not a section: passing Financials is refused with a hint to use statements. Asking for no sections and no statements is an error.
  • statements is any of income, balance and cashflow, as a list or a comma-separated string, and is empty by default. period is quarterly or yearly. periods is 1 to 40.
  • Output is trimmed and capped at about 150 KB. Officers, addresses, phone numbers and logos are dropped, long text is cut, holders are limited to ten per group, insider transactions to the newest ten, and a fund's full list of holdings is dropped. The newest periods are kept in each statement, in Earnings.History, Earnings.Trend, Earnings.Annual and in outstandingShares. When the cap is reached, the oldest periods go first. TRUNCATED says when something was cut.
  • Data: {symbol, name, type, quote_currency, updated_at, sections, period, statements, not_in_document}. period and statements appear only when statements were asked for. not_in_document appears only when a section or statement you asked for is absent from EODHD's document, and lists what is absent.
  • Guards: CURRENCY_MISMATCH, FILING_DATE_UNRELIABLE, DIVIDEND_YIELD_INCONSISTENT and EPS_BASIS. For stocks, a note says that new quarters usually arrive 1 to 4 days after the release. Another note appears when Earnings.History also lists quarters that have not reported yet (their epsActual is null), because those count towards periods.
  • Needs "Fundamentals Data Feed" or "All-in-One".
{"symbol": "NESN.SW", "sections": ["General", "Highlights"], "statements": ["income"], "period": "yearly", "periods": 3}
get_earnings_calendar

get_earnings_calendar(start=None, end=None, symbols=None, exchange=None, max_rows=200, offset=0)

Earnings dates. Cost: 1 for any window. The tool makes one whole-market request (cached for 6 hours) and filters by symbols and exchange itself, because a request per symbol would cost 1 each.

  • start defaults to today (UTC) and end to start plus 14 days. Calendar windows look forward, so nothing is clamped to today, and a past window shows reported results. The window is at most 90 days.
  • symbols is a list or a comma-separated string of at most 100; an empty list means no filter. With exchange too, a row must match both.
  • max_rows is 1 to 1000 and offset pages on.
  • Data: {start, end, total, offset, count, next_offset, earnings}, plus symbols and exchange when they were given. next_offset is null on the last page.
  • Rows are sorted by report_date, then symbol: {symbol, report_date, period_end, timing, currency, eps_actual, eps_estimate, surprise, surprise_pct}. timing is before_market, after_market or null. surprise and surprise_pct are null unless both eps_actual and eps_estimate exist, because EODHD sends a difference of 0 otherwise.
  • Guards: CALENDAR_TIMING_UNRELIABLE (always present), NO_CALENDAR_ROWS for requested symbols with no row, PARTIAL_COVERAGE for Tokyo rows, which have dates only, NOT_COVERED for UAE symbols and exchanges, which have none, RANGE_TRIMMED for rows outside the window, and TRUNCATED with the total, or EMPTY_RESULT when a page has no rows.
  • Needs "Fundamentals Data Feed" or "All-in-One". A 403 names the plans.
{"start": "2026-11-03", "end": "2026-11-10", "symbols": ["AAPL.US", "MSFT.US"]}
get_dividends

get_dividends(symbol, start=None, end=None, max_rows=100)

Dividend history. Cost: 1. Cached for 24 hours.

  • start and end limit the ex-date. Without start you get the full history, and without end declared future dividends are included. max_rows is 1 to 1000, and the newest rows are kept.
  • Rows run oldest first: {ex_date, declaration_date, record_date, payment_date, period, value, unadjusted_value, currency, upcoming}. value is split-adjusted and unadjusted_value is as declared. upcoming is true when the ex-date is after today (UTC), and a date EODHD does not have is null.
  • Data: {symbol, start, end, total, count, dividends}.
  • Guards: on a London line, which is quoted in GBX (pence), CURRENCY_MISMATCH when the dividends are in another currency (often GBP, which differs by a factor of 100) and CURRENCY_NOTE when they are in GBX too. RANGE_TRIMMED for rows EODHD returned outside the dates, and TRUNCATED when more rows match than max_rows. No dividends is EMPTY_RESULT, not an error.
  • A 403 names the plans.
{"symbol": "VOD.LSE", "start": "2025-01-01"}
get_dividend_calendar

get_dividend_calendar(date, max_rows=200, offset=0)

Symbols with a dividend dated on one day. Cost: 1 (assumed from EODHD's published price, not measured). Cached for 6 hours.

  • Rows: {symbol, date}. The calendar has no amounts, and a note points to get_dividends.
  • max_rows is 1 to 1000 and offset pages on. Data: {date, total, offset, count, next_offset, dividends}. total is null when EODHD gives none and more pages follow. TRUNCATED gives the next offset, and RANGE_TRIMMED and EMPTY_RESULT can appear.
{"date": "2026-11-05"}
get_splits

get_splits(symbol, start=None, end=None)

Split history. Cost: 1. Cached for 24 hours.

  • The tool sends no dates to EODHD. The history is short, so it takes all of it and filters by start and end here, and one cache entry serves every window.
  • Rows: {date, ratio, new_shares, old_shares, factor}, parsed from EODHD's "new/old" text. ratio is written new:old, and factor is new divided by old.
  • Data: {symbol, start, end, count, splits}.
  • Guards: ODD_SPLIT_RATIO when either side of the ratio is not a whole number, or when the factor lies between 0.8 and 1.25 and is not 1. These are usually spin-off or rights adjustments, not splits. No splits is EMPTY_RESULT.
  • A 403 names the plans.
{"symbol": "NVDA.US", "start": "2020-01-01"}
get_split_calendar

get_split_calendar(start=None, end=None, symbols=None, exchange=None, max_rows=200, offset=0)

Splits in a window. Cost: 1. Cached for 6 hours.

  • start defaults to today (UTC) and end to start plus 30 days. The window is at most 90 days, and nothing is clamped to today.
  • symbols (at most 100; an empty list means no filter) and exchange filter here, and a row must match both. max_rows is 1 to 1000 and offset pages on.
  • Rows: {symbol, split_date, old_shares, new_shares, factor, optionable}. Data: {start, end, total, offset, count, next_offset, splits}, plus symbols and exchange when they were given.
  • Guards: NOT_COVERED when a requested symbol or the exchange is on a market EODHD does not cover, RANGE_TRIMMED for rows outside the window, and TRUNCATED or EMPTY_RESULT for the page.
  • A 403 names the plans.
{"start": "2026-11-03", "end": "2026-12-03", "exchange": "US"}
list_exchanges

list_exchanges(query=None)

The exchanges EODHD lists. Cost: 1. Cached for 7 days.

  • Rows: {code, name, country, currency, operating_mic, country_iso2, country_iso3, coverage_note}. coverage_note is set for the Tokyo and UAE exchanges and null for the rest. Data: {query, list_size, count, exchanges}.
  • query is words, and every word must be part of the code, name or country, in any order.
  • Guards: PARTIAL_COVERAGE is always present. It says that Tokyo and the UAE exchanges (DFM and ADX) had no usable prices in the checks and that AE is listed but empty, so being on the list is not proof of coverage. EMPTY_RESULT when nothing matches.
{"query": "india"}
list_exchange_symbols

list_exchange_symbols(exchange, query=None, type=None, delisted=False, max_rows=100, offset=0)

Symbols on one exchange. Cost: 1. One list is cached for 24 hours per exchange and delisted flag, and filtering and paging happen locally, so a large list never goes into the model's context, and further pages or filters on the same list cost 0.

  • query is words from the code, name or ISIN, in any order. type is matched case-insensitively against EODHD's type, with spaces as underscores: common_stock, etf, fund, preferred_stock and so on.
  • max_rows is 1 to 500 and offset is 0 to 1,000,000.
  • Rows: {symbol, code, name, type, currency, country, isin, venue}, with symbol as CODE.<requested exchange>.
  • Data: {exchange, delisted, query, type, list_size, total, offset, count, next_offset, symbols}. list_size is the whole list, total is the number of matches, and next_offset is null on the last page.
  • delisted=true returns delisted lines, which search never finds. The US delisted list has about 60,000 rows.
  • Guards: NOT_COVERED for Tokyo and the UAE (AE included), NOT_FOUND for an unknown exchange, NO_DATA for a list that is empty (AE is listed but empty, so you meet this only with EODHD_GUARD_ALLOW_UNCOVERED on), TRUNCATED with the total and the next offset, and EMPTY_RESULT when nothing matches.
{"exchange": "SW", "query": "nestle", "type": "common_stock", "max_rows": 10}
search_symbols

search_symbols(query, exchange=None, type="all", max_results=15)

Look a company up by name or code. Cost: 1. Cached for 24 hours.

  • query is 1 to 100 characters, and a / is read as a space. exchange limits the search to one exchange. max_results is 1 to 50. type is one of all, stock, etf, fund, bonds, index or crypto.
  • Data: {query, exchange, type, count, results}. Rows: {symbol, code, exchange, name, type, country, currency, isin, is_primary, previous_close, previous_close_date, possibly_inactive}.
  • possibly_inactive is true when previous_close_date is missing, or EODHD_GUARD_STALE_SESSIONS or more sessions behind the latest session expected for that exchange.
  • Guards: POSSIBLY_INACTIVE for such lines, PARTIAL_COVERAGE for rows on exchanges with gaps, TRUNCATED when EODHD returns more rows than asked for, and EMPTY_RESULT for no match. A note says delisted lines are not searchable and points to list_exchange_symbols.
  • Search does not return a frozen line either. In a check in November 2026, a name search for Roche returned ROP.SW and never ROG.SW. check_symbol falls back to the exchange's delisted list for that reason.
{"query": "Roche", "exchange": "SW", "type": "stock"}
check_symbol

check_symbol(symbol, include_quote=False, find_successor=True)

Is this code alive? Cost: 1 to 5 calls, and 0 for an uncovered market.

  1. Coverage check. A gap returns not_covered, with a NOT_COVERED warning, and costs nothing.
  2. The last 14 days of end-of-day bars (1 call, cached for 1 hour). A 404 means not_found. An empty answer, or a newest bar EODHD_GUARD_STALE_SESSIONS or more sessions behind the latest session expected, means stale.
  3. With include_quote, a quote (1 call). "NA" fields mean no_quote and a 404 means missing. Either one makes the status no_quote when the bars are current.
  4. If the status is not ok and find_successor is true, up to 3 more calls look for the line's name and for other codes of the same company:
    1. A search by code, limited to the exchange. Only a row with this very code on this exchange gives the name and ISIN. The same code on another exchange is another company: ROG.US is not ROG.SW.
    2. If the search no longer returns the line, the exchange's delisted list. It is the list list_exchange_symbols pages, so a call made by either tool is free for the other. It is skipped when the bars are current and only the quote is missing, because that line is not delisted.
    3. A search by the company name, limited to the exchange.

The candidates are other codes on the same exchange with a recent previous close, at most five. Those whose ISIN starts with the same two letters as the dead line's come first, because a re-keyed line can get a new ISIN.

Data: {symbol, status, last_bar_date, expected_latest_session, sessions_behind, quote_status, name, isin, successor_candidates, advice}.

  • status is ok, stale, not_found, no_quote or not_covered. When several apply, not_found wins over stale, which wins over no_quote.
  • quote_status is ok, no_quote, missing or error, and null when no quote was asked for. It keeps what the status hides.
  • successor_candidates rows are {symbol, name, isin, previous_close_date}. Candidates are a lead to confirm, not a verdict.
  • advice is a sentence or two: what was found, what the delisted list says about the line when it was consulted, any step that failed, and what to do next.
  • A failed optional step keeps the verdict. If the quote call or the successor search fails, status stays as found, advice says what went wrong, and quote_status is error for a failed quote. A failure of the end-of-day call itself, other than a 404, is an error.
  • Warnings: STALE_DATA (without the advice to run check_symbol, because this is that tool), NO_QUOTE and NOT_COVERED.
{"symbol": "ROG.SW", "include_quote": true}
get_usage

get_usage(days=7, include_account=True)

What this server has spent. Cost: 0.

  • days is 1 to 90. include_account=false makes no network request.
  • Data: ledger (calls, cost and cache hits for each GMT day, for today per endpoint, and for this session), budget (daily_limit, spent_today, remaining, resets_at; remaining is null without a limit), account (EODHD's own counters), account_error (only when the account call failed) and cost_table.
  • The account part comes from EODHD's free account endpoint and is limited to api_requests_today, requests_date, daily_rate_limit, extra_limit, subscription_type and subscription_mode. It never includes a name, email address, payment method or invite token.
  • If the account call fails, a missing key included, the tool still returns the ledger and the budget, with account: null and an account_error that says why. When the call is made, meta.endpoints shows user. A note appears when EODHD's counter is dated another day than today.
  • EODHD counts per GMT day. The account counter also includes other apps that share your key, while the ledger counts only this server.
{"days": 7}

Warnings and errors

Warning codes

Warnings travel next to the data in warnings. They never replace an error, and they are not optional reading. Each is {code, message, details}, where details holds structured fields such as dates and counts, or is null.

CodeRaised byMeaning
STALE_DATAget_eod_prices, check_symbolThe newest bar is EODHD_GUARD_STALE_SESSIONS or more sessions behind the latest session expected
FILLER_BARS_REMOVEDget_eod_pricesZero-volume bars with open = high = low = close were dropped (holiday filler)
RANGE_TRIMMEDget_eod_prices, get_intraday_prices, get_dividends, the earnings, dividend and split calendarsEODHD returned rows outside the requested range; they were dropped
RANGE_CLAMPEDget_eod_prices, get_intraday_pricesAn end in the future was clamped to today (end-of-day) or to now, rounded up to the next quarter hour (intraday). The calendars look forward, so they never clamp
TRUNCATEDany list, and get_fundamentalsMore rows than max_rows, or an answer over the size cap; the details say what was kept and how to page on
SPINOFF_ADJUSTED_CLOSEget_eod_pricesThe first bar returned is dated before a known spin-off or demerger that EODHD folds into close
CURRENCY_NOTEget_eod_prices, get_quote, get_dividendsLondon lines are usually quoted in GBX (pence)
CURRENCY_MISMATCHget_fundamentals, get_dividendsStatements or dividends use another currency than the quote
DELAYED_QUOTEget_quoteQuotes are 15 to 20 minutes delayed (always present)
STALE_QUOTEget_quoteThe quote's date is EODHD_GUARD_STALE_SESSIONS or more sessions behind the latest session expected
NO_QUOTEget_quote, check_symbolEODHD answered with "NA" fields, or a 404, for a symbol
INTRADAY_NOT_SAME_DAYget_intraday_pricesThe window reaches today's session, which is open or has closed, and EODHD has no bars for it yet
NO_INTRADAY_DATAget_intraday_pricesNo bars in the window
FILING_DATE_UNRELIABLEget_fundamentalsfiling_date equals the period end, so it is not the publication date
DIVIDEND_YIELD_INCONSISTENTget_fundamentalsHighlights.DividendYield is 0 while a forward dividend rate is set
EPS_BASISget_fundamentalsEPS actuals follow the analyst (adjusted) basis
CALENDAR_TIMING_UNRELIABLEget_earnings_calendarBefore and after-market flags were often wrong in checks (always present)
NO_CALENDAR_ROWSget_earnings_calendarRequested symbols with no row in the window
NOT_COVEREDget_quote, get_earnings_calendar, get_split_calendar, check_symbolA requested symbol or exchange is on a market EODHD does not cover. The call still answers for the rest
PARTIAL_COVERAGEget_earnings_calendar, search_symbols, list_exchangesTokyo rows with dates only, search hits on a market with gaps, and on list_exchanges (always) a reminder that being listed is not proof of coverage
POSSIBLY_INACTIVEsearch_symbolsA line's previous close is missing or old: it may be delisted or renamed
ODD_SPLIT_RATIOget_splitsA ratio that looks like a spin-off or rights adjustment, not a split
EMPTY_RESULTget_eod_prices, get_dividends, get_splits, the calendars, list_exchanges, list_exchange_symbols, search_symbolsA legitimately empty answer, for example no dividends, or a page past the last row
BUDGET_LOWany tool80% or more of the daily budget is spent
Error codes

A failed call is an MCP error result. After the SDK's Error executing tool <name>:, its text begins with the code, then a message that names the field or the cause, and a hint where one helps. Error messages never contain the API key.

CodeWhenBilled
CONFIG_ERRORA setting is invalid, no API key is set, or the state directory cannot be usedNo call made
INVALID_PARAMSAn argument is invalid. The message names the field, the value received and what is acceptedNo call made
NOT_COVEREDThe market is known to have no data (Tokyo, the UAE)No call made
NOT_FOUNDEODHD answered 404, for example an unknown symbol or exchangeNo
NO_DATAHTTP 200, but nothing usable: an empty list for a range with trading days, only "NA" quotes, an empty document or symbol list. The message says what the call costYes, if it was a fresh 200
AUTH_ERROR401: the key is missing, wrong or expiredNo
PLAN_ERROR403: your subscription does not include this endpoint. The message names the plans that do where the server knows them, and otherwise says that a higher plan mayNo
QUOTA_EXCEEDED402: your account's daily API limit is used up. It resets at 00:00 GMTNo
RATE_LIMITED429, after retriesNo
BUDGET_EXCEEDEDThis server's daily budget would be exceeded. Nothing was sentNo call made
UPSTREAM_ERROR5xx after retries, or any other unexpected status, such as another 4xx or a redirect (redirects are never followed)No
NETWORK_ERRORA network failure or timeout after retries. Only the exception class name is reportedNo
BAD_RESPONSEHTTP 200 with a body that is not JSON, or has the wrong shapeYes, it was a 200

Costs, budget and usage

EODHD charges in API calls, and each endpoint has its own price. The server knows the prices, reports the cost of every response in meta.cost, and counts every call in a local ledger.

EndpointAPI calls
End-of-day prices1
Intraday bars5
Real-time quotes1 per symbol sent
Fundamentals10 (one request returns the whole document)
Earnings calendar1 for any window
Split calendar1
Dividend calendar1 (assumed from EODHD's published price, not measured)
Dividend and split history1 each
Exchange list, exchange symbol list, search1 each
Account counters0

check_symbol is built from these: 1 for the bars, 1 for a quote if you ask for one, and up to 3 for the successor search, which makes 5 at most.

These prices were measured in October 2026. Only HTTP 200 answers are billed: 401, 402, 403 and 404 were measured as free. A cached answer costs 0.

Daily budget. EODHD_GUARD_DAILY_BUDGET sets a cap in API calls per GMT day. The default is 1000, and 0 means no cap. Before each call that is not served from the cache, the server checks that today's spend, plus the cost of calls still in flight, plus the call's own cost stays within the cap. If it would not, the call is not made and the tool returns BUDGET_EXCEEDED, which names the budget, today's spend, the variable that raises it and the reset time. From 80% of the cap, every response also carries BUDGET_LOW. Cached answers and the free get_usage still work when the budget is used up. The budget is a guard of this server's own, separate from your EODHD plan's limit, so set it at or below that limit.

The day. EODHD's daily counter resets at 00:00 GMT, and the ledger uses GMT days too.

get_usage. Ask for it before bulk work. It shows today's spend per endpoint, the remaining budget, the last days, and EODHD's own counters. See get_usage.

Cache and state files

The server keeps two SQLite files in its state directory. The directory is EODHD_GUARD_STATE_DIR if set, else $XDG_STATE_HOME/eodhd-guard-mcp, else ~/.local/state/eodhd-guard-mcp, and %LOCALAPPDATA%\eodhd-guard-mcp on Windows. The folder is created with mode 700 and the files with mode 600, so only you can read them on Linux and macOS.

FileHoldsKept
usage.sqliteOne row per HTTP answer or cache hit: time, GMT day, endpoint, cost, HTTP status, cached or not, duration, size and symbol. Never the API key, URLs or response bodies90 days
cache.sqliteEODHD responses, zlib-compressed. Only 200 and 404 answersPer endpoint, see below
DataCached for
End-of-day prices6 hours when end is within a day of today, else 24 hours
Intraday bars15 minutes when the window reaches today's trading session, else 24 hours
Quotes60 seconds
Fundamentals24 hours
Earnings, split and dividend calendars6 hours
Dividend and split history24 hours
Exchange list7 days
Exchange symbol lists and search24 hours
check_symbol: its bars, and its quote1 hour, and 60 seconds
404 answers24 hours
Account countersNever

meta.cached tells you whether an answer came from the cache, and meta.fetched_at carries the time EODHD produced it.

  • Turn the cache off with EODHD_GUARD_CACHE=off. The server then reads and writes no cache and creates no cache.sqlite.
  • Clear the cache by stopping the server and deleting cache.sqlite (and any cache.sqlite-wal and cache.sqlite-shm files next to it).
  • Deleting usage.sqlite resets the local budget counter, because the budget is computed from the ledger. EODHD's own counter is not affected.

The cache holds licensed EODHD data. Keep the state directory out of git repositories, shared folders and backups that others can read, and do not hand the files to anyone else. See the data licence notice.

Security

  • The key comes from EODHD_API_KEY_FILE or EODHD_API_KEY. It is never a tool argument, so the model cannot read it or set it.
  • The key is sent only to the EODHD API, as the api_token query parameter EODHD expects. Only EODHD_GUARD_BASE_URL, which you set yourself, can change where it goes, and the server refuses a base URL that is not https (localhost excepted) or that carries credentials. It never follows a redirect, so a redirect cannot carry the key to another host.
  • The key is never logged. Log records, the MCP SDK's included, pass through a redaction filter, and the HTTP client's request logging, which would print the URL, is held at WARNING so that it stays silent. The server sets up the filter after the SDK has configured logging, so it covers the SDK's handlers too.
  • The key is never cached, never stored in the ledger, and never returned in a tool result or an error message. Network errors report the exception class name only. If EODHD ever echoed the key in a response body, it would be masked before the body is cached or returned.
  • get_usage returns counters, never the account holder's name, email address, payment method or invite token.
  • The streamable-HTTP transport has no authentication. It binds to 127.0.0.1 by default.
  • Names and descriptions in results come from EODHD. Treat them as data, not as instructions.

To report a vulnerability, follow SECURITY.md. Do not open a public issue for it.

Data caveats

docs/caveats.md lists the quirks of EODHD's data that this server guards against, with what was observed in October and November 2026 and what the server does about each: coverage gaps, frozen codes, "NA" quotes, ignored parameters, raw and adjusted prices, spin-offs, NSE closes, filler bars, publication lag, intraday and quote delays, fundamentals, calendars, delisted names and holiday handling.

Compared with EODHD's official MCP server

EODHD maintains its own MCP server, EODHD-MCP-Server, which is also hosted at mcp.eodhd.com. It is the broader tool, and the right choice when you need endpoints this project does not wrap. This project has no bulk downloads, screener, options, news or technical indicators, for example. It is a narrow complement for the cases where correctness and cost matter, and the two can run side by side.

The right-hand column comes from checks in November 2026. EODHD may have changed things since, so test the version you use.

eodhd-guard-mcpEODHD's official MCP server
Maintained byAn independent projectEODHD
Scope14 toolsAbout 100 tools
Size of the tool listAbout 12,000 tokensAbout 37,000 tokens, in clients that load every tool
Where it runsOn your machine, over stdio or localhost HTTPHosted at mcp.eodhd.com
AuthenticationYour key, read from your environmentv1: the key in the URL or a header. v2: OAuth
Empty and "NA" answersBecome errors or explicit warningsPassed through as successful results
Fundamentals10 API calls on the first read, output capped at about 150 KBA call cost 80 API calls and returned about 1 MB
Account detailsget_usage returns counters onlyThe account tool returned the account holder's details to the model

The token figures are estimates of the size of the tools/list answer, which is what a client that loads every tool puts into the model's context. For this server that answer is about 48 KB, or roughly 12,000 tokens.

On 2026-11-03 the gaps found in those checks were filed as issues on the official repository, so that they can be fixed at the source. They are listed here for anyone who wants to follow them, and their status may have changed since:

  • #74: empty and "NA" answers are returned as successes.
  • #75: fundamentals cost 80 API calls per call and about 1 MB.
  • #76: the documentation of close.
  • #77: last_close is returned without a date.
  • #78: get_user_details returns the account holder's personal data.
  • #79: dates written as digits only are read as Unix time.
  • #80: an unrelated section in CLAUDE.md.
  • #81: .env.example has drifted from the code.

Use the official server for breadth. Use this one for prices, quotes, fundamentals and calendars, where an empty or placeholder answer, a mislabelled price or a surprise bill would hurt. Neither replaces checking the numbers that matter against a second source.

Development

CONTRIBUTING.md covers setup, running the tests, the fixture policy (synthetic data only, never real EODHD responses), how to add a tool and how a release is made. The design of every module, tool and warning code is in docs/DESIGN.md. Changes are listed in CHANGELOG.md. Everyone taking part is expected to follow the code of conduct.

pip install -e ".[dev]"
pytest
ruff check . && ruff format --check . && mypy

The suite has more than 2,000 tests and covers more than 99% of the code (CI fails below 85%). They run against a mock and make no network calls. Contract tests replay real EODHD answers from a local cache that is git-ignored and never committed, to check that every guard fires on the real shapes. A live check on 2026-11-03 used 7 API calls and confirmed four things against the real API: end-of-day bars for AAPL.US cost 1 and the repeat came from the cache at 0; the quote for ROG.SW came back as no_quote; check_symbol on ROG.SW returned stale with ROP.SW as a successor candidate; and a Tokyo symbol, 7203.TSE, gave NOT_COVERED.

Licence

MIT. The licence covers this code only. It does not cover EODHD's data, which has its own terms.

Frequently asked questions

What is EODHD Guard?

EODHD market data that catches silent failures: frozen codes, NA quotes, empty answers.

How do I install EODHD Guard?

It runs on your machine. In Claude Code: claude mcp add eodhd-guard -e 'EODHD_API_KEY=<your eodhd_api_key>' -e 'EODHD_GUARD_DAILY_BUDGET=1000' -- uvx eodhd-guard-mcp==0.1.0. For other clients, paste the JSON configuration from this page into their MCP settings.

Does EODHD Guard need an API key?

Yes. It expects EODHD_API_KEY, which you set in its configuration.

Is EODHD Guard open source?

Yes. Its code is on GitHub under the MIT license.

Who maintains EODHD Guard?

It is published by techfreakworm. AI.tn lists it but does not maintain or endorse it.

From the official MCP Registry. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.