Metadata-Version: 2.4
Name: woning-mcp
Version: 0.1.1
Summary: woning — MCP server for Dutch house hunting: funda.nl search, rental sources, and Dutch mortgage borrowing capacity
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx>=0.28.1
Requires-Dist: mcp[cli]>=1.0
Requires-Dist: pyfunda>=3.1.4
Requires-Dist: pypararius>=2.1.2
Description-Content-Type: text/markdown

# woning-mcp

Your AI makelaar (real estate agent) for the Dutch housing market. Search [funda.nl](https://www.funda.nl), compare listings, calculate Dutch mortgages, and estimate buying costs — all from Claude.

[![PyPI](https://img.shields.io/pypi/v/woning-mcp)](https://pypi.org/project/woning-mcp/)
[![CI](https://github.com/victorlane/woning-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/victorlane/woning-mcp/actions/workflows/ci.yml)

## Quick start

**Prerequisites:** Install [uv](https://docs.astral.sh/uv/) (Python package runner) if you don't have it:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

### Claude Code

From a local checkout (use this while developing — it picks up your edits):

```bash
claude mcp add makelaar -- uv run --project /ABSOLUTE/PATH/TO/woning-mcp woning-mcp
```

Or the released version from PyPI:

```bash
claude mcp add makelaar -- uvx woning-mcp
```

> **Note:** the PyPI release is pinned to an older `pyfunda`. funda's search API
> now rejects those requests with HTTP 400, so install from a local checkout
> until a new version is published.

Verify it registered:

```bash
claude mcp list          # should show: makelaar
```

### Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "makelaar": {
      "command": "FULL_PATH_TO_UV",
      "args": [
        "run",
        "--project",
        "/ABSOLUTE/PATH/TO/woning-mcp",
        "woning-mcp"
      ]
    }
  }
}
```

Replace `FULL_PATH_TO_UV` with the output of `which uv` (e.g. `/Users/you/.local/bin/uv`). Claude Desktop doesn't inherit your shell PATH, so the full path is required.

Then restart Claude Desktop.

---

## What you can ask

> Search for apartments in Amsterdam under €400k with energy label A or better

> I earn €80,000/year, my partner earns €50,000. Can we afford this €500,000 house? We're first-time buyers, both 29. Include NHG.

> Compare these two listings: https://www.funda.nl/detail/koop/amsterdam/appartement-rapenburgerstraat-103-f/43363178/ and https://www.funda.nl/detail/koop/amsterdam/huis-arendonksingel-54/43363912/

> What are the total buying costs for a €350,000 apartment? I'm a starter, age 27.

> Show me the price history for this listing: https://www.funda.nl/detail/koop/amsterdam/huis-arendonksingel-54/43363912/

---

## Tools

### `search_listings`

Search funda.nl with filters for location, price, area, property type, energy label, and more. Supports multi-city search and pagination (15 results/page).

### `get_listing`

Full property details by listing ID or funda.nl URL.

### `get_price_history`

Historical asking prices, sale prices, and WOZ values for a listing.

### `compare_listings`

Side-by-side comparison of 2–10 properties: price, area, price/m², bedrooms, year built, energy label, garden.

### `calculate_dutch_mortgage`

Dutch-specific mortgage calculator covering:
- **Annuity & linear** mortgage types
- **NHG** (Nationale Hypotheek Garantie) — eligibility, premium, interest discount
- **Hypotheekrenteaftrek** — mortgage interest deduction (36.97%) minus eigenwoningforfait
- **Startersvrijstelling** — 0% transfer tax for first-time buyers age 18–34
- **NIBUD max mortgage** — income-based borrowing limit with partner income and student debt

### `calculate_total_cost`

Itemized breakdown of bijkomende kosten (additional buying costs):

| Cost | Typical amount |
|------|---------------|
| Overdrachtsbelasting (transfer tax) | 0% / 2% / 10.4% |
| Notariskosten (notary) | €1,500 – €2,500 |
| Taxatiekosten (appraisal) | ~€600 |
| Hypotheekadviseur (mortgage advisor) | ~€2,500 |
| Bankgarantie (bank guarantee) | ~€500 |
| Kadaster (land registry) | ~€150 |
| NHG premium (optional) | 0.6% of mortgage |
| Aankoopmakelaar (optional) | ~1.5% of price |

These costs must come from savings — you can't borrow them.

---

## Disclaimer

This project is **not affiliated with, endorsed by, or connected to funda.nl** or Funda B.V. Property data is sourced from third-party services and may be incomplete or outdated.

Mortgage calculations and cost estimates are **approximations for informational purposes only — not financial advice**. Dutch mortgage rules, tax rates, and NHG limits change annually. Always consult a licensed mortgage advisor (*hypotheekadviseur*) before making financial decisions.

See [LICENSE](LICENSE) for the full MIT license.

---

## Acknowledgements

Built on [pyfunda](https://github.com/0xMH/pyfunda) by [0xMH](https://github.com/0xMH) — the Python library that makes funda.nl data accessible.

---

## Development

```bash
git clone https://github.com/victorlane/woning-mcp.git
cd woning-mcp
uv sync
uv run pytest                              # run tests
uv run ruff check src/ tests/              # lint
uv run mcp dev src/woning/server.py  # MCP Inspector
```

---

## Listing monitor

A standalone script that watches funda.nl on a schedule and appends **only listings it has never seen before** to a CSV, ready for review by you or an agent. It does not need Claude or the MCP server — it is plain Python and safe to run from cron.

```bash
uv run woning-monitor            # one pass
uv run woning-monitor --dry-run  # show what would be written, touch nothing
```

Each run:

1. searches every enabled **stream** across its locations,
2. compares each listing's ID against the seen-IDs state file,
3. fetches one detail page per genuinely new listing (for coordinates, construction year and photo URL, which funda's search results omit),
4. appends the new ones to `new_listings.csv` and records their IDs.

### Configure

Edit [`monitor_config.toml`](monitor_config.toml). Three streams ship pre-configured — `rent` (≤ €1.300), `nieuwbouw` (new build ≤ €400k) and `koop` (resale ≤ €400k) — each with its own locations, price ceiling, filters and `enabled` toggle.

```toml
[monitor]
csv_path = "new_listings.csv"
state_path = "monitor_state.json"
request_delay = 1.0        # seconds between requests
seed_on_first_run = true   # see "First run" below
fetch_details = true
detail_fetch_limit = 40

[streams.koop]
enabled = true
locations = ["utrecht", "amersfoort", "woerden", "den-haag", "amstelveen", "nieuwegein", "ijsselstein"]
category = "buy"           # "buy" | "rent" | "sold"
construction_type = "resale"
max_price = 400000
sort = "newest"
max_pages = 3              # 15 listings per page, per location
```

**Location slugs must be lowercase and hyphenated**: `den-haag`, not `den haag`. A wrong slug is not an error — funda returns an empty result set — so any location yielding 0 listings is logged as a `WARNING`.

Valid filters: `category`, `status`, `min_price`/`max_price`, `min_area`/`max_area`, `min_plot`/`max_plot`, `min_rooms`/`max_rooms`, `min_bedrooms`/`max_bedrooms`, `min_construction_year`/`max_construction_year`, `object_type`, `energy_label`, `construction_type`, `radius_km`, `sort`. Anything else is rejected at startup.

### First run

On a virgin state file the monitor **seeds**: it records everything currently listed as already seen and writes nothing to the CSV, so you don't get a few hundred pre-existing listings dumped on you. From then on only genuinely new listings are reported. Use `--no-seed` if you do want the current market written out on the first run.

### CSV output

Appended in place, header written once. Columns: `first_seen` (UTC), `stream`, `listing_id`, `global_id`, `address`, `postcode`, `city`, `neighbourhood`, `price`, `living_area`, `plot_area`, `price_per_m2`, `rooms`, `bedrooms`, `object_type`, `construction_year`, `energy_label`, `status`, `publication_date`, `latitude`, `longitude`, `project`, `broker`, `url`, `photo_url`, and a blank `reviewed` column for you or an agent to fill in.

### Cron

Write your own crontab entry (`crontab -e`). Every 30 minutes, with output appended to a log:

```cron
*/30 * * * * cd /Users/victorbrinkhorst/Personal/Projects/woning-mcp && /Users/victorbrinkhorst/.local/bin/uv run woning-monitor >> monitor.log 2>&1
```

Both paths must be absolute — cron does not inherit your shell `PATH` or working directory. Use the output of `which uv` for the binary. Overlapping runs are prevented by a lock file, so a slow run can never double-write.

### CLI

| Flag | Effect |
|------|--------|
| `--config PATH` | Config file (default: `monitor_config.toml` in the working directory) |
| `--csv PATH` | Override CSV output path |
| `--state PATH` | Override state file path |
| `--stream NAME` | Run only this stream; repeatable. Also runs disabled streams |
| `--dry-run` | Report findings without writing CSV or state |
| `--seed` / `--no-seed` | Override `seed_on_first_run` |
| `--verbose` | Debug logging |

Logs go to stderr with timestamps. Exit code is `0` on success, `1` if every stream failed, `2` on a bad config.

### Unattended-safety

- One failing stream or location never aborts the run; the rest continues.
- A stream that had any location fail does **not** get its IDs marked as seen, so nothing is silently lost.
- The state file is written atomically; a corrupt state file is backed up (`monitor_state.json.corrupt.<ts>`) rather than wiped.
- A lock file guards against overlapping cron invocations, and a lock left by a killed process is detected and reclaimed.
- Pagination is capped per location, so a broken filter can't paginate forever.

---

## Rental sources beyond funda

funda's free-sector rental supply below ~€1300 is thin, and in some towns it is
**empty** — Woerden and Amersfoort both return zero rentals on funda but have
listings elsewhere. So rentals are searched across more than one site.

| Source | Access | Reliability |
|---|---|---|
| **funda.nl** | `pyfunda` (mobile API) | Reliable |
| **ikwilhuren.nu** | server-rendered HTML | Reliable — no bot protection |
| **Pararius** | `pypararius` | Best-effort; Cloudflare returns intermittent HTTP 403 |

Use the `search_rentals_all_sources` tool to query them together. Pararius is
off by default because it is frequently blocked; enable it with
`include_pararius=true` and expect it to sometimes return nothing.

**Not integrated, and why:**

- **Huurwoningen.nl** — serves a Cloudflare *managed challenge* site-wide
  (`cf-mitigated: challenge`), including `/sitemap.xml`. There is no sanctioned
  automated path, so it is not scraped. Check it manually.
- **Kamernet** — rooms only.
- **WoningNet / sociale huur** — login-gated, and irrelevant above the
  ~€48.6k/yr income ceiling.

All adapters are **read-only**. They never contact an agent, request a viewing,
or submit an application — which is also what these sites' `robots.txt` files
disallow (`/contact/`, `/plan-een-bezichtiging`, `/request-details/`).

### Monitoring ikwilhuren

Set `source = "ikwilhuren"` on a stream in `monitor_config.toml`. Because
ikwilhuren ignores query-string filters, such a stream sweeps the national feed
(~40 pages) and filters locally, so it accepts `min_price`/`max_price` only.
Keep `min_price` set — the feed also carries parking spaces and garages, some as
low as €97/mnd.
