watchcharts-mcp
# watchcharts-mcp
[](https://github.com/NiccoloSalvini/watchcharts-mcp/actions/workflows/ci.yml)
[](LICENSE)
MCP server for [watchcharts.com](https://watchcharts.com) — luxury watch market prices and trends, exposed as tools for Claude and other MCP clients.
## How it works
WatchCharts is protected by a Cloudflare JS challenge that blocks plain HTTP clients and vanilla headless browsers. This server uses [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) (stealth-patched Playwright) driving your system **Google Chrome** in headful mode, with the window parked off-screen. API calls run inside the page via `fetch()`, reusing the browser's cookies, TLS fingerprint and CSRF token.
The private REST API was reverse-engineered from HAR captures of the `/market` screener and the marketplace:
| Endpoint | Purpose |
|---|---|
| `GET watchcharts.com/rest/screener_count` | Count of watches matching filters |
| `GET watchcharts.com/rest/market_results` | Paginated screener results (DataTables format, HTML fragments parsed into JSON) |
| `GET watchcharts.com/suggest/analytics/0.json?q=…` | Model/brand autocomplete (pure JSON) |
| `GET watchcharts.com/watch_model/{id}/overview` | Model page: full specs, market/retail price (parsed) |
| `GET watchcharts.com/charts/watch/{id}.json?type=trend\|sales\|listings` | Price series, auction sales, historical listings (pure JSON) |
| `GET watchcharts.com/charts/brand.json` / `charts/brand/{id}.json` | Overall / per-brand market index since 2017 (pure JSON) |
| `GET watchcharts.com/watches?filters=<b64>&page=&sort=` | Full catalog browse with spec filters (server-rendered, parsed) |
| `GET marketplace.watchcharts.com/rest/ebay` | Live eBay listings aggregation |
| `GET marketplace.watchcharts.com/listings?q=…` | Marketplace listing search (server-rendered, parsed) |
| `GET marketplace.watchcharts.com/listing/{id}?html=true` | Listing detail with value assessment |
`filters` is base64-encoded JSON: key `-1` = price cap, keys `-100`…`-105` = time period (1m…5y) with minimum trend %. Brand filtering uses the `brandId` query param. Each subdomain has its own CSRF token (read from `#csrfToken[data-token]`), but the Chrome context/cookies are shared.
## Requirements
- macOS/Linux/Windows with **Google Chrome** installed
- Python ≥ 3.12, [uv](https://docs.astral.sh/uv/)
## Tools
| Tool | Description |
|---|---|
| `search_watches(price_max, period, min_trend_pct, brand_id, start, limit)` | Screener results: name, collection, watch_id, url, image, market price (EUR), trend % |
| `count_watches(query, brand_id, price_max, period, min_trend_pct)` | Count matching watches |
| `list_brands()` | All brands with WatchCharts brand ids |
| `search_models(query)` | Resolve free text ("daytona 116503") to watch_id, uuid, brand, collection, price |
| `get_watch_info(watch_id)` | Full specs (references, complications, case, dial), market + retail price, per-variation prices |
| `get_watch_sales(watch_id)` | Auction sale records (Sotheby's, Christie's...) with hammer price |
| `get_listings_history(watch_id)` | Historical sold/unsold listings across eBay, dealers, forums |
| `get_price_history(watch_id)` | Daily market price series (~1y on free tier) + retail price |
| `get_market_index(brand_id)` | Overall or per-brand market index, daily since 2017 |
| `browse_watches(filters, page, sort)` | Filter the 29k+ catalog by specs (dial, diameter, movement, complications...) |
| `search_ebay(query, fallback_query, watch_id, country)` | Live eBay listings: title, seller, price, URL |
| `search_listings(query, page)` | Marketplace listings (dealers, Reddit, forums): price, Fair/Good/High rating, country |
| `get_listing(listing_id)` | Listing detail: price, value assessment vs estimate, source, external URL |
| `appraise_watch(query, condition, accessory, region)` | Instant appraisal: estimated value adjusted for condition, box/papers, region |
`watch_id` from `search_watches` composes with `get_price_history` and `search_ebay`. `variation_id` from `get_watch_info` narrows `get_price_history`, `get_watch_sales`, and `get_listings_history` to a single reference/dial.
### Appraisal (captcha-gated, driven via the real form)
`appraise_watch` is the equivalent of the paid API's appraisal.
**Usage:**
```
appraise_watch(
query="Rolex Daytona 116503", # name or reference
condition="Pre-owned", # or "New" / "Unworn"
accessory="box and papers", # or "box only" / "watch only"
region="Europe", # or "North America" / "Asia"
)
# → {"watch": "Rolex Cosmograph Daytona 116503",
# "estimated_value": "€17,671",
# "condition": "Pre-owned", "accessory": "Watch with original box and papers",
# "region": "Europe", "summary": "..."}
```
`condition` / `accessory` / `region` are matched by substring against the form's dropdown labels, so partial words work. The response echoes back the label actually matched. The inputs move the number — e.g. new + watch-only + North America → €18,058.
**Why it's different from the other tools.** Its submit endpoint is protected by a per-request captcha token that only the page's own JS can mint. There is **no token stored anywhere in this code** — each call drives the real `/appraisal` form (type reference → pick the match → set the dropdowns → submit), and the browser mints a fresh token at submit time. That's why "will it still work tomorrow?" is a yes: nothing is cached that can expire. The only prerequisites are the same as every other tool — Chrome installed and Cloudflare passing.
**What can break it.** Because it's UI-driven, it depends on the form's DOM structure. If WatchCharts redesigns the `/appraisal` form, update `APPRAISAL_SELECTORS` in `client.py` — that dict is the single place all the selectors live. The parsing of the rendered result is separate (`_parse_appraisal_report`) and covered by an offline fixture test, so a wording change in the report surfaces as a test failure. It's ~10-15s per call (a real browser flow) and more fragile than the JSON-backed tools — use it for one-off valuations, not bulk lookups.
## Install
```bash
git clone https://github.com/NiccoloSalvini/watchcharts-mcp
cd watchcharts-mcp
uv sync
```
### Claude Code
```bash
claude mcp add watchcharts -- uv run --directory /path/to/watchcharts-mcp watchcharts-mcp
```
### Claude Desktop
```json
{
"mcpServers": {
"watchcharts": {
"command": "uv",
"args": ["run", "--directory", "/path/to/watchcharts-mcp", "watchcharts-mcp"]
}
}
}
```
## Development
```bash
uv run pytest # offline parser tests against fixtures in tests/fixtures
```
Parsers are pinned by fixture tests: if WatchCharts redesigns its markup, tests fail instead of tools returning silently empty data.
## Notes
- First tool call launches Chrome and solves the Cloudflare challenge (~15–30 s); later calls are fast. The Chrome profile is cached in `~/.cache/watchcharts-mcp/chrome-profile` so subsequent launches reuse `cf_clearance`.
- Free-tier data: results are capped by WatchCharts (2000 rows) and some columns require a Professional subscription.
- For personal/research use. Respect WatchCharts' terms of service.
TDQS
Scored across 15 tools
Each tool targets a distinct operation or data facet (e.g., appraisal, catalog browsing, price history, marketplace listings, auction records, brand listing). Descriptions clearly differentiate their purposes, leaving no ambiguity.
All tools use a consistent `verb_noun` pattern with underscores (e.g., `get_listing`, `search_models`, `list_brands`). Verbs are uniform (get, search, list, browse, count, evaluate, appraise) and no mixing of conventions occurs.
With 15 tools, the surface is well-scoped for a watch data API. It covers catalog, pricing, marketplace, auctions, and deals without being excessive or too sparse.
The tool set provides comprehensive read coverage: catalog browsing, model search, price history, market index, appraisal, marketplace listings (general and eBay), auction records, and deal evaluation. There are no obvious missing operations for the domain.