{
  "markdown": "# willmehr\n\nMCP servers for hunting deals on the German-speaking classifieds sites. TypeScript,\nover stdio locally or Streamable HTTP self-hosted; works with Claude Code, Claude\nDesktop, or any other MCP client.\n\nTwo servers ship from this repo, each registered separately:\n\n| Server | Site | Entry point |\n| --- | --- | --- |\n| `willmehr` | [willhaben.at](https://www.willhaben.at) (Austria) | `dist/index.js` |\n| `kleinanzeigen-mcp` | [kleinanzeigen.de](https://www.kleinanzeigen.de) (Germany) | `dist/kleinanzeigen/index.js` |\n\nThey publish as two npm packages, [`willmehr`](https://www.npmjs.com/package/willmehr)\nand [`kleinanzeigen-mcp`](https://www.npmjs.com/package/kleinanzeigen-mcp), released\ntogether from this repo at the same version.\n\nThe willhaben server was built by reverse-engineering the willhaben web app's own JSON\nAPI from a HAR capture. The Kleinanzeigen server is a port of\n[`jnslmk/kleinanzeigen-mcp`](https://github.com/jnslmk/kleinanzeigen-mcp) — see\n[Kleinanzeigen](#kleinanzeigen) below.\n\n## Quick start\n\n```bash\nclaude mcp add willmehr -- npx -y willmehr\nclaude mcp add kleinanzeigen -- npx -y kleinanzeigen-mcp\n```\n\nOr from a clone, which is what you want for hacking on it or self-hosting:\n\n```bash\ngit clone https://github.com/taneron/willmehr.git && cd willmehr\nnpm install && npm run build\n\nclaude mcp add willmehr -- node \"$PWD/dist/index.js\"\nclaude mcp add kleinanzeigen -- node \"$PWD/dist/kleinanzeigen/index.js\"\n```\n\nEither way you get both servers locally, in `full` mode. Both are listed in the\nofficial MCP registry, as `io.github.taneron/willmehr` and\n`io.github.taneron/kleinanzeigen`, so a client that reads the registry can find them\nwithout this README. The willhaben account tools need\na session cookie of your own — see [Authentication](#authentication); everything else\nworks anonymously and needs no setup.\n\nTo run it as a shared HTTP server instead, on your own box, see [Hosting](#hosting).\nNothing in this project phones home to anyone else's instance.\n\n\n## Modes\n\nBoth servers run in one of two modes. Same code, same binary — the mode is chosen at\nstartup with `--public` / `--full` or `WILLMEHR_MODE`, and defaults to `full`.\n\n| | `full` (default) | `public` |\n| --- | --- | --- |\n| willhaben account tools | registered | **not registered** |\n| `.env` / `WILLHABEN_COOKIE` | loaded | never read; ignored with a warning on stderr |\n| willhaben request spacing | 400 ms | 900 ms |\n| Kleinanzeigen spacing / pages / batch | 800 ms / 5 / 20 | 1500 ms / 3 / 10 |\n\n`public` is what makes this safe to host for other people: the process holds no\ncredential, and registers no tool that could use one, so a shared deployment cannot\nleak a session it never loads. The tighter pacing is the other half — in `public` mode\nevery user shares one outbound IP, and both sites rate-limit per IP.\n\n```bash\nnode dist/index.js --public               # willhaben, anonymous\nWILLMEHR_MODE=public node dist/kleinanzeigen/index.js\n```\n\nAccount tools deliberately have no hosted equivalent. willhaben authenticates through\nKeycloak with PKCE, so there is no scoped token a third party could be granted — the\nonly thing a hosted server could ask for is the raw session cookie, which reads\nmessages and acts as the account. Anyone who wants those four tools runs `full` mode\nlocally with their own cookie. See [Hosting](#hosting).\n\n\n# willhaben\n\n## What it does\n\nFour public tools, no account needed:\n\n| Tool | Purpose |\n| --- | --- |\n| `willhaben_search` | Search the marketplace with structured filters; auto-paginates |\n| `willhaben_get_ad` | Full detail for one ad: description, images, seller trust signals |\n| `willhaben_find_deals` | Price the market for a query, rank listings below the median |\n| `willhaben_discover_filters` | Resolve willhaben's opaque numeric category/brand ids |\n\nFour account tools, requiring a session cookie:\n\n| Tool | Purpose |\n| --- | --- |\n| `willhaben_my_profile` | Your profile and numeric user id |\n| `willhaben_my_watchlist` | Ads saved to your watchlist |\n| `willhaben_my_conversations` | Recent buyer/seller chats |\n| `willhaben_my_alert_count` | Number of active saved-search alerts |\n\n## Install\n\n```bash\nclaude mcp add willmehr -- npx -y willmehr\n```\n\nOr from source:\n\n```bash\nnpm install\nnpm run build\nnpm run pack:kz                      # stage build/kleinanzeigen-mcp for publishing\n```\n\n`willmehr` publishes from the repo root. `kleinanzeigen-mcp` publishes from\n`build/kleinanzeigen-mcp`, staged by `npm run pack:kz` — it copies the built output the\nKleinanzeigen entry point reaches (its own directory plus the shared mode module) and\ngenerates a manifest around it, so both packages ship the same commit at the same\nversion. `server.json` and `server.kleinanzeigen.json` are the matching MCP registry\nentries.\n\nRegister it with Claude Code:\n\n```bash\nclaude mcp add willmehr -- node \"$PWD/dist/index.js\"\n```\n\nOr in a client's MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"willmehr\": {\n      \"command\": \"node\",\n      \"args\": [\"/path/to/willmehr/dist/index.js\"],\n      \"env\": { \"WILLHABEN_COOKIE\": \"BBX_JSESSIONID=...; x-bbx-csrf-token=...\" }\n    }\n  }\n}\n```\n\n## Authentication\n\nwillhaben authenticates through Keycloak with PKCE, so there is no username/password\ngrant to automate. Instead, lift the session from a logged-in browser:\n\n1. Log in to willhaben.at, then visit your profile or messages so the page loads an\n   authenticated endpoint.\n2. DevTools → Network → export dropdown → **\"Export HAR (with sensitive data)\"**. The\n   plain download button strips cookies and produces a file this cannot use.\n3. `npm run session -- ~/Downloads/www.willhaben.at.har`\n4. `npm run check:auth`\n\n`npm run session` writes `.env` (mode `0600`, gitignored) and never prints cookie\nvalues. `npm run check:auth` exercises all four account tools and stops at the first\n`401` rather than repeatedly failing auth against your account.\n\nThe cookie must contain `BBX_JSESSIONID` (the session) and `x-bbx-csrf-token`. The\nCSRF token is read out of the cookie automatically — willhaben's web app mirrors that\ncookie into a same-named request header on every `/webapi/` call, and the server\nreproduces that. `WILLHABEN_CSRF_TOKEN` only exists to override it.\n\nThe server loads `.env` from the package root itself, so MCP clients don't need to\npass the cookie through their config.\n\n### Sessions expire faster than the cookie claims\n\n`BBX_JSESSIONID` is sent with a five-day `Max-Age`, but the server invalidates it well\nbefore that — a session captured and replayed **38 minutes later** was already\nrejected. It is not IP- or header-binding: the same jar, replayed verbatim with the\nbrowser's complete header set from the same machine and IP, still returns `401` while\nanonymous requests continue to work.\n\nTreat the session as short-lived. Re-run `npm run session` right before you need the\naccount tools, rather than expecting a capture to keep working for days.\n\n### Environment variables\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `WILLHABEN_COOKIE` | — | Session cookie for account tools |\n| `WILLHABEN_CSRF_TOKEN` | from cookie | Override the CSRF token |\n| `WILLHABEN_MIN_INTERVAL_MS` | `400` (`900` public) | Minimum spacing between requests |\n| `WILLMEHR_MODE` | `full` | `full` or `public` — see [Modes](#modes) |\n\n## Using it well\n\n**Resolve ids before filtering.** Categories and brands are opaque integers. Ask\n`willhaben_discover_filters` first — it returns each filter value with its id *and* a\nhit count for your current query:\n\n> Which laptop brands have listings under €300 in Vienna right now?\n\n**Narrow before ranking deals.** `willhaben_find_deals` compares each listing against\nthe median of its result set, so the query has to describe one kind of item. Searching\n`thinkpad` mixes €900 laptops with €5 docking stations and every \"discount\" it reports\nis noise. Searching `thinkpad x1 carbon` inside category `5831` (Notebooks) with\n`priceFrom: 150` gives a median worth measuring against.\n\n**Watch for `suspiciouslyCheap`.** Any listing under 20% of the median is flagged.\nSometimes that's a genuine bargain; more often it's a placeholder price, a parts-only\nlisting, or bait. The flag is a prompt to read the ad, not a verdict.\n\n**Sniping.** Combine `sort: \"newest\"` with `maxAgeHours: 2` to see only what appeared\nin the last couple of hours.\n\n### Example\n\n> Find me underpriced ThinkPad X1 Carbons from private sellers in Vienna, posted in\n> the last day, and tell me which are worth messaging about.\n\nThe agent chains `willhaben_discover_filters` → `willhaben_find_deals` →\n`willhaben_get_ad` on the top candidates to check seller age and ad text.\n\n## How it works\n\nNotes from the HAR analysis, since none of this is documented publicly:\n\n- **`x-wh-client` is required.** Every `/webapi/` route returns a bare `400` with an\n  empty body without it. This looks exactly like a malformed query parameter and is\n  the single easiest thing to get stuck on.\n- **Search needs no cookies at all.** Only account routes do.\n- **Filters are self-describing.** Search responses carry `navigatorGroups[]`, where\n  each facet declares the query parameter it maps to and every legal value with a hit\n  count. `willhaben_discover_filters` is a thin projection of that, which is why it\n  stays correct when willhaben adds categories.\n- **Sort ids are undocumented integers.** Verified empirically:\n  `1` newest, `3` price ascending, `4` price descending, `0` relevance.\n- **There is no public single-ad JSON endpoint.** `publicapi.willhaben.at` and\n  `api.willhaben.at` both refuse anonymous callers. The ad page is Next.js, so the\n  full payload is read from `__NEXT_DATA__`, using the lighter\n  `/_next/data/<buildId>/...json` route when the buildId is known and falling back to\n  HTML when willhaben redeploys and the id rotates.\n- **Every field arrives as a `{name, values[]}` bag.** `src/normalize.ts` flattens it.\n\n## Rate limiting and terms\n\nRequests are serialised through a single queue with a 400 ms floor between them, plus\nexponential backoff with jitter on `429`/`5xx`. Please leave that in place.\n\nBe aware that willhaben's `robots.txt` states *\"It is expressively forbidden to use\nspiders, search robots or other automatic methods to access willhaben.at\"*, disallows\n`/webapi/`, and specifically disallows the `keyword`, `PRICE_FROM`, `PRICE_TO` and\n`periode` parameters this server uses. Their Terms of Use likewise prohibit automated\ncopying without consent. This tool performs exactly that kind of access.\n\nThat is a real constraint, not a formality: it's your account that would be suspended.\nThis is built for personal, low-volume, occasional use against your own account. Don't\npoint it at bulk collection, don't redistribute the data, and don't run it on a tight\nschedule.\n\n## Prior art\n\n[`aliildan/willhaben-mcp`](https://github.com/aliildan/willhaben-mcp) covers more\nverticals (cars, real estate, jobs) but is anonymous-only and has no price analysis.\nIf you want broad read-only browsing rather than deal hunting on the marketplace, use\nthat one instead.\n\n# Kleinanzeigen\n\nA port of [`jnslmk/kleinanzeigen-mcp`](https://github.com/jnslmk/kleinanzeigen-mcp)\n(Python) to TypeScript, covering [kleinanzeigen.de](https://www.kleinanzeigen.de),\nGermany's largest classifieds site. No account needed.\n\n## Tools\n\n| Tool | Purpose |\n| --- | --- |\n| `kleinanzeigen_search` | Search by keyword, location, radius and price; auto-paginates |\n| `kleinanzeigen_search_by_url` | Search from a pasted Kleinanzeigen URL, keeping filters the structured search can't express |\n| `kleinanzeigen_get_listing` | Full detail for one ad: description, images, seller, attributes |\n| `kleinanzeigen_get_listings_batch` | Full detail for several ids at once — the normal follow-up to a search |\n| `kleinanzeigen_parse_search_url` | Explain which filters a URL encodes. Makes no network request |\n\nThe intended flow is `kleinanzeigen_search` → pick interesting ids →\n`kleinanzeigen_get_listings_batch`. Search results carry only the site's own teaser\ntext, so a broad search does not blow up the model's context window.\n\n## Install\n\nIts own package, so it installs without the willhaben half:\n\n```bash\nclaude mcp add kleinanzeigen -- npx -y kleinanzeigen-mcp\n```\n\nOr from source:\n\n```bash\nnpm install && npm run build\nclaude mcp add kleinanzeigen -- node \"$PWD/dist/kleinanzeigen/index.js\"\n```\n\nOr in a client's MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"kleinanzeigen\": {\n      \"command\": \"node\",\n      \"args\": [\"/path/to/willmehr/dist/kleinanzeigen/index.js\"]\n    }\n  }\n}\n```\n\n### Environment variables\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `KZ_MIN_INTERVAL_MS` | `800` (`1500` public) | Minimum spacing between requests |\n| `KZ_MAX_PAGE_COUNT` | `5` (`3` public) | Cap on pages per search (~25 listings each) |\n| `KZ_MAX_BATCH_SIZE` | `20` (`10` public) | Cap on ids per batch call |\n| `WILLMEHR_MODE` | `full` | `full` or `public` — see [Modes](#modes) |\n| `TZ` | host default | Listing dates are timezone-less; set `Europe/Berlin` to be exact |\n\n## What changed in the port\n\n**No browser.** The Python original drives a headless Chromium through\n[`DanielWTE/ebay-kleinanzeigen-api`](https://github.com/DanielWTE/ebay-kleinanzeigen-api),\npinned at a commit and baked into a ~1.5 GB image. That turns out to be unnecessary:\nsearch results, ad detail pages and the view counter are all fully server-rendered and\nanswer a plain `GET` with a browser-shaped `User-Agent`. This port is `fetch`-only,\nwhich removes the container, the ~1.5 GB memory floor and the browser-context pool\nalong with the deadlock and context-leak bugs the original had to patch around.\n\n**One request queue instead of a semaphore.** Kleinanzeigen blocks parallel requests\nfrom one IP even when they are staggered, so every request — search page, ad page,\nview counter — is serialised through a single queue with an 800 ms floor and\nexponential backoff with jitter on `429`/`5xx`. That replaces upstream's\n`KZ_MAX_CONCURRENT`/`KZ_MAX_CONTEXTS` knobs, which were browser-pool sizing.\n\n**One name per thing.** Upstream returned `adid` when searching but `id` on detail\npages, and named the same knob `page_count` on one tool and `max_pages` on another;\nthe Python server carried compatibility shims for both. Neither drift exists here, so\nthe shims are gone: it is `id` and `pages` everywhere.\n\n**Typed values instead of strings.** Prices come back as\n`{amount, label, negotiable, previousAmount}` rather than a pre-mangled string —\n`amount: 0` means *Zu verschenken* (free), `null` means no figure was shown. A gone ad\nraises `ListingGoneError` instead of returning a hollow object full of nulls, and in a\nbatch call it lands in `errors` while the rest of the ids still return.\n\n**Fields the original dropped**, because they change what a listing means:\n\n- `wanted` — a *Gesuch* listing is someone looking to **buy**. Upstream returned these\n  mixed in with offers, which reads to a model as a suspiciously cheap sale.\n- `distanceKm` — the results page appends \"(4 km)\" to the location when a radius is\n  set; that belongs to the search, not the ad, so it is split out.\n- `shippingPossible`/`tags`, `previousAmount`, and `seller.shopUrl`.\n- Locality parts are named for what they are. Upstream parsed\n  \"13088 Pankow - Weissensee\" into `{zip, city: \"Weissensee\", state: \"Pankow\"}`; here\n  it is `{postcode, city: \"Pankow\", district: \"Weissensee\"}`.\n\n**View counts are opt-in.** `#viewad-cntr-num` is the one thing on the page that JS\nfills in, from `/s-vac-inc-get.json`. That endpoint answers a plain `GET`, so the count\nis still reachable — but the \"inc\" is literal: reading it increments the seller's\ncounter exactly as opening the page would. Pass `includeViewCount: true` when you want\nit; it costs one extra request per ad.\n\n**Not ported:** the HTTP transport and `/healthz` route. Both servers here are stdio,\nmatching how they get registered with a local client.\n\n## Caveats\n\nKleinanzeigen has no public API, so this reads the site's HTML. It can break whenever\nthey change their markup — which is what the parsing checks in `npm run smoke:kz` are\nfor. Heavy or parallel use trips bot detection, and a datacenter IP will get `403`s\nwhere a home connection does not.\n\nScraping is also at odds with Kleinanzeigen's terms of service. The request throttle is\nthere for a reason; please leave it in place and keep this to personal-scale use.\n\n# Hosting\n\n`dist/serve-http.js` serves both servers over MCP's Streamable HTTP transport, for\nanyone who would rather point a client at a URL than install anything.\n\n```bash\nnpm run build && npm run serve      # http://0.0.0.0:8080\ndocker build -t willmehr-http . && docker run -p 8080:8080 willmehr-http\n```\n\n| Endpoint | Contents |\n| --- | --- |\n| `POST /willhaben/mcp` | the four public willhaben tools |\n| `POST /kleinanzeigen/mcp` | all five Kleinanzeigen tools |\n| `GET /healthz` | liveness probe |\n\n```bash\nclaude mcp add --transport http willhaben https://<your-host>/willhaben/mcp\nclaude mcp add --transport http kleinanzeigen https://<your-host>/kleinanzeigen/mcp\n```\n\nThe mode is pinned to `public` and cannot be overridden: `WILLMEHR_MODE=full` makes the\nhost refuse to start rather than expose the operator's own account to every caller.\n\nTransports are stateless — a fresh server per request, nothing retained between them.\nThe two upstream clients are process-wide on purpose: their queues are what hold the\nwhole deployment to one polite stream of traffic per site.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |\n| `MCP_RATE_BURST` | `10` | Requests one IP may burst |\n| `MCP_RATE_PER_MINUTE` | `20` | Refill rate per IP |\n| `MCP_MAX_INFLIGHT` | `8` | Concurrent tool calls before `503` |\n| `MCP_MAX_BODY_BYTES` | `1000000` | Request body ceiling |\n| `MCP_REQUEST_TIMEOUT_MS` | `120000` | Ceiling on one request |\n| `TRUST_PROXY` | off | Take the client IP from `X-Forwarded-For` (set behind a reverse proxy) |\n| `MCP_ALLOWED_ORIGINS` | unset (allow) | Comma-separated `Origin` allowlist |\n| `MCP_ALLOWED_HOSTS` | unset (allow) | Comma-separated `Host` allowlist |\n\n## Deploying to a fresh VPS\n\nA €4/month box is enough — this is I/O-bound waiting on two websites, not CPU-bound.\nPut it on a machine that does nothing else: if a classifieds site blocks the IP, that\nshould cost you nothing but this.\n\n1. **Create the VPS.** Debian 12 or Ubuntu 24.04, smallest size (Hetzner CX22, DO\n   basic, whatever). Add your SSH key at creation.\n2. **Point DNS at it.** An `A` record for the subdomain you want, at the box's IPv4.\n   Do this first — Caddy needs the name to resolve before it can be issued a\n   certificate. Check with `dig +short mcp.example.com`.\n3. **Prepare the box** (installs Docker, opens 80/443, enables unattended upgrades):\n\n   ```bash\n   ssh root@<ip> 'bash -s' < deploy/bootstrap-server.sh\n   ```\n\n4. **Deploy:**\n\n   ```bash\n   HOST=root@<ip> MCP_HOSTNAME=mcp.example.com ./deploy/deploy.sh\n   ```\n\n   It syncs the tree, writes the server's `.env`, builds, starts, and waits for\n   `https://mcp.example.com/healthz`. Re-run it to ship a change; Caddy's certificate\n   lives in a named volume and is not re-issued.\n\n5. **Register it:**\n\n   ```bash\n   claude mcp add --transport http willhaben https://mcp.example.com/willhaben/mcp\n   claude mcp add --transport http kleinanzeigen https://mcp.example.com/kleinanzeigen/mcp\n   ```\n\n`deploy.sh` excludes `.env` and `*.har` from the sync on purpose — the local ones hold\na willhaben session cookie and the capture it came from. The server writes its own\n`.env` containing nothing but the hostname.\n\nOperating it:\n\n```bash\nssh <host> 'cd /opt/willmehr && docker compose logs -f --tail 100'\nssh <host> 'cd /opt/willmehr && docker compose restart mcp'\ncurl -s https://mcp.example.com/healthz     # mode, in-flight count, endpoints\n```\n\n\n## What hosting costs you\n\nNot secrets — those never reach the server. It costs you an IP. Locally each user\nscrapes from their own address; hosted, everyone's traffic leaves through yours, and\nboth sites bot-detect per IP. Concurrent Kleinanzeigen requests from one address trip\ndetection even when spaced, which is why the outbound queue is global and why the\npublic defaults are slower. Expect to need response caching and, past a handful of\nusers, egress proxies. Datacenter ranges belonging to the large serverless platforms\nare challenged most aggressively.\n\nHosting a scraper for third parties is also a different posture towards both sites'\nterms than scraping for yourself. Keep the deployment small, unmonetised, and easy to\nturn off.\n\n# Development\n\n```bash\nnpm run typecheck                    # tsc --noEmit\nnpm run smoke                        # end-to-end checks against the live willhaben API\nnpm run smoke:kz                     # end-to-end checks against live kleinanzeigen.de\nnpm run session -- <path-to.har>     # refresh the account session from a HAR\nnpm run check:auth                   # verify the session + account tools\nnpm run build\n```\n\n`scripts/smoke.ts` is a live integration test: it asserts that filters actually narrow\nresults, that `price_asc` really sorts ascending, that ad detail round-trips from a\nsearch result, and that deal ranking is ordered and below-median.\n\n`scripts/smoke-kleinanzeigen.ts` does the same for the Kleinanzeigen server, and puts\nthe pure parsing (German dates, prices, URL filters, page injection) under test without\na network call — so a failure there is a logic bug, while a failure below it means the\nsite's markup moved.\n\n# License\n\nMIT — see [LICENSE](LICENSE). The Kleinanzeigen server is a port of\n[`jnslmk/kleinanzeigen-mcp`](https://github.com/jnslmk/kleinanzeigen-mcp) (MIT,\n© Jonas Lemke), whose notice is kept in [NOTICE](NOTICE).\n\nNeither site endorses this. It reads public pages and the willhaben web app's own JSON\nAPI at a deliberately slow pace; if you host it for others, read\n[What hosting costs you](#what-hosting-costs-you) first.\n",
  "bytes": 22015,
  "sha": "acb823c11f8f18944657195584a68d2fbb2928256ecddc8ab98c9fbf2a0cb76f",
  "repo_slug": "taneron/willmehr",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_taneron_kleinanzeigen_bd7b1bb4/readme"
}