tradingview-mcp-proof
README.md
# tradingview-mcp-proof
An MCP server for the failure that costs money when TradingView data reaches a
language model: **the feed stops, the tool keeps answering, and Claude analyses
forty-minute-old prices as if they were the live market.**
This repo does not demonstrate "building an MCP server". The official SDK
documentation covers that in a page. It demonstrates, with runnable evidence,
that a server handing market state to a model:
1. **never lets a stale reading pass as current** -- age, missing bars and a
usability verdict travel with every value;
2. **never double-counts a redelivered alert**, and never lets a late one
rewrite the present;
3. **reports holes in history instead of closing them**, including the case
where the newest bar is perfectly fresh and the window behind it is broken;
4. **rejects forged alerts**, because TradingView signs nothing.
You can verify all of it in about five minutes, on your machine, **without a
TradingView account, without an Anthropic key and without any network access**.
No credentials of mine are involved anywhere.
## Why this is hard with TradingView specifically
A webhook alert is **fire-and-forget HTTP**. There is no delivery receipt, no
retry and no ordering guarantee. If your endpoint is slow, restarting or
briefly unreachable, that alert is gone -- and nothing downstream is told.
Four more traps compound it:
* **`{{time}}` and `{{timenow}}` are different clocks.** `{{time}}` is the
**open** of the bar that triggered the alert; `{{timenow}}` is when the alert
fired. The freshness of a *market view* is governed by the bar, not by the
firing, and using the wrong one hides staleness behind a recent timestamp.
* **`{{interval}}` renders intraday resolutions as a bare number of minutes.**
`"60"` is one hour. Read it as seconds and every hourly bar looks 59 minutes
stale; hard-code a unit and daily bars break instead.
* **An alert that fires once per bar arrives before the bar closes.** Its
values can still change. Treated as settled history, it quietly corrupts the
series.
* **There is no signature.** No HMAC header, no shared-secret challenge --
anyone who learns the URL can post a signal the model will then reason over.
And the trap specific to putting any of this in front of an LLM: **a model has
no clock and no feed.** Handed `{"signal": "long", "price": 21850.25}` it will
analyse that as the present, because nothing in the payload says otherwise. It
cannot caveat what it cannot see.
## The design under test
* **Freshness is computed server-side and travels with the value.** Not a
timestamp the model is expected to interpret -- an explicit
`usable_for_analysis` verdict plus the reason in words.
* **The ledger is append-only and keyed by `alert_id`.** A redelivery is a
no-op. Idempotency is what makes one signal one signal.
* **Current state is ordered by bar time, never by arrival.** A late packet is
stored as history and never becomes the present. Discarding it instead would
manufacture a gap that was not there.
* **A close revises a provisional reading; nothing revises a close.**
* **Gaps inside the window are counted and reported**, separately from
staleness at the edge, because a fresh newest bar over a holed history is the
case that looks healthy and is not.
* **The server does not claim to know why data stopped.** It cannot distinguish
a feed outage from a session break without a market calendar, so it reports
the observable gap and refuses to call either one live. Guessing here is how
a server cries wolf every weekend until the operator stops believing it.
## Verify it in five minutes
```bash
git clone https://github.com/guillemustafa-ux/tradingview-mcp-proof
cd tradingview-mcp-proof
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pytest -v
```
```text
test_staleness.py::test_naive_tool_presents_a_40_minute_old_reading_as_current PASSED
test_staleness.py::test_hardened_tool_refuses_to_call_the_same_reading_current PASSED
test_staleness.py::test_a_fresh_closed_bar_is_usable PASSED
test_staleness.py::test_one_missed_bar_is_already_stale PASSED
test_staleness.py::test_empty_symbol_is_not_a_silent_none PASSED
test_delivery.py::test_redelivered_alert_is_stored_once PASSED
test_delivery.py::test_a_late_bar_never_becomes_the_present PASSED
test_delivery.py::test_bar_close_revises_the_provisional_reading PASSED
test_delivery.py::test_a_close_is_never_revised_by_a_later_provisional PASSED
test_gaps.py::test_hole_inside_the_window_is_reported_not_interpolated PASSED
test_gaps.py::test_a_contiguous_window_reports_no_gap PASSED
test_gaps.py::test_recovery_makes_the_newest_bar_fresh_while_history_stays_holed PASSED
test_auth.py::test_forged_alert_never_enters_the_ledger PASSED
test_auth.py::test_missing_secret_is_rejected PASSED
test_auth.py::test_absent_and_wrong_secret_are_indistinguishable PASSED
test_auth.py::test_hourly_interval_is_read_as_minutes_not_seconds PASSED
test_server.py::test_server_exposes_the_three_tools PASSED
test_server.py::test_stale_verdict_survives_the_protocol_round_trip PASSED
test_server.py::test_feed_health_answers_without_being_asked_for_a_price PASSED
test_server.py::test_tool_survives_being_dispatched_on_another_thread PASSED
20 passed
```
### What each scenario proves
| # | Test | Situation | Proven |
|---|------|-----------|--------|
| 1a | `test_naive_tool_presents_a_40_minute_old_reading_as_current` | feed dead 40 min | **the anti-pattern, demonstrated**: the tutorial-shaped tool returns a payload with no temporal field at all, byte-identical to the one it gave when the data was fresh |
| 1b | `test_hardened_tool_refuses_to_call_the_same_reading_current` | same ledger, same instant | `STALE`, `age_seconds: 2400`, `missing_bars: 6`, `usable_for_analysis: false`, plus an instruction in words |
| 1c | `test_one_missed_bar_is_already_stale` | exactly one dropped alert | staleness does not require an outage; a single drop breaks the claim |
| 1d | `test_a_fresh_closed_bar_is_usable` | healthy feed | no false alarm -- the verdict is usable and no warning is attached |
| 1e | `test_empty_symbol_is_not_a_silent_none` | never-seen symbol | `EMPTY`, not a null that reads as "no signal" |
| 2a | `test_redelivered_alert_is_stored_once` | TradingView re-fires the same alert | stored once; one signal stays one signal |
| 2b | `test_a_late_bar_never_becomes_the_present` | bar 2 arrives after bar 3 | the present does not rewind, and the late bar is still kept as history |
| 2c | `test_bar_close_revises_the_provisional_reading` | intrabar alert, then its close | provisional while open, revised by the close, flagged throughout |
| 2d | `test_a_close_is_never_revised_by_a_later_provisional` | stray intrabar after a close | settled history is not reopened |
| 3a | `test_hole_inside_the_window_is_reported_not_interpolated` | bars 2 and 3 dropped | the hole is counted and named; the missing bars are absent, not guessed |
| 3b | `test_recovery_makes_the_newest_bar_fresh_while_history_stays_holed` | feed recovers | **"the feed is back" and "the data is usable" are two different claims** |
| 3c | `test_a_contiguous_window_reports_no_gap` | clean window | no false positive |
| 4a | `test_forged_alert_never_enters_the_ledger` | wrong secret | rejected before storage |
| 4b | `test_absent_and_wrong_secret_are_indistinguishable` | missing vs wrong secret | identical error, so the endpoint is not an oracle |
| 4c | `test_hourly_interval_is_read_as_minutes_not_seconds` | 1-hour chart | `{{interval}}` of `"60"` is 3600s, so an hourly feed is not permanently "stale" |
| 5a | `test_server_exposes_the_three_tools` | real MCP wiring | the tools exist on an actual `MCPServer` |
| 5b | `test_stale_verdict_survives_the_protocol_round_trip` | real `call_tool` | the verdict reaches the client through the protocol, not just the unit test |
| 5c | `test_tool_survives_being_dispatched_on_another_thread` | SDK threading | see below |
## The drill
```bash
python scripts/drill.py
```
Replays a 5-minute feed that stops at 14:15 and prints what each server hands
to Claude at four points in time:
```text
=== Feed healthy -- wall clock 14:21 UTC ===
naive server -> {"symbol": "NQ1!", "signal": "long", "price": 21850.25}
this server -> {"price": 21850.25, "status": "FRESH", "age_seconds": 360,
"missing_bars": 0, "usable_for_analysis": true}
=== Feed down for 40 minutes -- wall clock 14:55 UTC ===
naive server -> {"symbol": "NQ1!", "signal": "long", "price": 21850.25}
this server -> {"price": 21850.25, "status": "STALE", "age_seconds": 2400,
"missing_bars": 6, "usable_for_analysis": false}
warning -> DO NOT present this as the current market. newest bar is
2400s old on a 300s chart; 6 bar(s) that should have arrived
did not. [...]
```
The naive line never changes. Same four fields at 14:21 and at 14:55 -- so a
model reading it answers *"NQ is long at 21850.25"* with equal confidence on
data that is four seconds old and on data that is forty minutes dead.
## Two things that bite in production and not in tests
**The SDK renamed `FastMCP`.** In the Python SDK 2.x it is
`from mcp.server.mcpserver import MCPServer`. Every
`from mcp.server.fastmcp import FastMCP` example online is v1 code and will not
import. The package raises a pointed migration error rather than a bare
`ModuleNotFoundError`, which is a kindness worth knowing about.
**The SDK runs synchronous tools on a worker thread.** Tool functions are
dispatched through `anyio.to_thread.run_sync`, so a connection opened on one
thread gets used from another. A default `sqlite3` connection raises
`ProgrammingError: SQLite objects created in a thread can only be used in that
same thread` on the **first real client call** -- while every direct-call unit
test keeps passing. This repo hit exactly that, fixed it with
`check_same_thread=False` plus a lock, and pinned it with
`test_tool_survives_being_dispatched_on_another_thread`.
## Wiring it to Claude Desktop
```json
{
"mcpServers": {
"tradingview": {
"command": "/absolute/path/to/.venv/bin/python",
"args": ["-m", "tradingview_mcp_proof.server"],
"env": {
"TVMCP_LEDGER": "/absolute/path/to/tradingview-alerts.db"
}
}
}
}
```
Ingest and serving are deliberately separate: your webhook receiver writes to
the ledger, the MCP server only reads from it. Coupling them means a slow model
call can drop an alert, and TradingView will not send it again.
## Deliberately out of scope
Honesty about the edges is part of the argument:
* **No market-session calendar.** The server reports the observable gap and
refuses to guess whether it is an outage or a closed market. Supplying
session hours would let it distinguish the two; that is a configuration
decision an operator should make, not a default this repo invents.
* **No HTTP receiver.** The transport in front of the ledger is a thin concern
and varies per deployment (FastAPI, a Cloudflare Worker, a tunnel).
`Ledger.record()` is the seam.
* **No price-history backfill.** This models the alert stream, which is what a
Pine strategy actually emits. Reconstructing full OHLC needs a data vendor
and is a different problem.
* **One symbol per query.** No fan-out, no subscriptions, no streaming.
## Licence
MIT.
TDQS
A3.6/5.0
Scored across 3 tools
Disambiguation5/5
Each tool targets a clearly distinct concern: feed liveness, current market state, and recent bar history. There is no overlap because the metadata exposed by each tool is unique to its purpose.
Naming Consistency5/5
All three tools follow the same get_<noun> pattern in lower_snake_case. The naming is perfectly uniform and predictable.
Tool Count5/5
Three tools is a minimal but well-scoped set for a market-data proof server. Each tool earns its place and adds a non-redundant capability.
Completeness4/5
The set covers the core lifecycle of checking whether data is live, retrieving a current state, and inspecting recent bars. It lacks broader historical queries or symbol metadata, but those are reasonable gaps for a proof-focused server.
Maintenance
ActivityMaintained
ResponsivenessNo issues