Skip to main content
Glama
README.md
<div align="center">

<img src="docs/assets/banner.svg" alt="finn-seller-mcp β€” let Claude sell your stuff on finn.no" width="100%"/>

# πŸ›’ finn-seller-mcp

**An MCP server that lets Claude (or any MCP client) search _and sell_ on [finn.no](https://www.finn.no) β€” Norway's largest marketplace.**
Send a photo, get a finished Torget ad: description, category, market price, duplicate check, publish.

[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![MCP](https://img.shields.io/badge/MCP-server-7c3aed)](https://modelcontextprotocol.io/)
[![Playwright](https://img.shields.io/badge/Playwright-Chromium-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/python/)
[![Tests](https://github.com/ml-senior/finn-seller-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/ml-senior/finn-seller-mcp/actions/workflows/test.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Tools](https://img.shields.io/badge/MCP%20tools-20-0ea5e9)](#-tool-reference)

⭐ **If this saves you time, star the repo β€” it helps others find it.**

[English](README.md) Β· [Русский](docs/README.ru.md)

</div>

---

<div align="center">

| πŸš€ [Quick Start](#-quick-start) | 🧰 [Tools](#-tool-reference) | πŸ”„ [How it works](#-how-it-works) | πŸ›‘οΈ [Safety](#%EF%B8%8F-safety-model) |
|:---:|:---:|:---:|:---:|
| 🧭 [Recon](#-recon-mapping-the-site-yourself) | βš™οΈ [Config](#%EF%B8%8F-configuration) | 🩺 [Troubleshooting](#-troubleshooting) | ❓ [FAQ](#-faq) |

</div>

---

## πŸ“– Table of contents

- [✨ What it does](#-what-it-does)
- [πŸ†š Why this project](#-why-this-project)
- [πŸš€ Quick Start](#-quick-start)
- [πŸ’¬ Example session](#-example-session)
- [πŸ”„ How it works](#-how-it-works)
- [🧰 Tool reference](#-tool-reference)
- [πŸ’° Pricing policy](#-pricing-policy)
- [πŸ” Duplicate protection](#-duplicate-protection)
- [πŸ›‘οΈ Safety model](#%EF%B8%8F-safety-model)
- [🧭 Recon: mapping the site yourself](#-recon-mapping-the-site-yourself)
- [βš™οΈ Configuration](#%EF%B8%8F-configuration)
- [πŸ—‚οΈ Project layout](#%EF%B8%8F-project-layout)
- [πŸ§ͺ Development & tests](#-development--tests)
- [🩺 Troubleshooting](#-troubleshooting)
- [❓ FAQ](#-faq)
- [πŸ—ΊοΈ Roadmap](#%EF%B8%8F-roadmap)
- [🀝 Contributing](#-contributing)
- [βš–οΈ Disclaimer](#%EF%B8%8F-disclaimer)
- [πŸ“„ License & credits](#-license--credits)

---

## ✨ What it does

<table>
<tr>
<td width="50%" valign="top">

### 🏷️ Sell
- πŸ“Έ **Photo prep** β€” auto-rotate, strip EXIF/GPS, resize, HEIC (iPhone) support
- ✍️ **Ad writing** β€” the assistant writes title + Norwegian description from your photos
- πŸ—‚οΈ **Category from photos** β€” uses finn.no's own category prediction
- πŸ’° **Market price** β€” median of comparable ads, outliers removed
- πŸ” **Duplicate guard** β€” won't re-list something you already listed or sold
- 🚦 **Two-step publish** β€” private draft on finn.no β†’ preview β†’ your "yes" β†’ live

</td>
<td width="50%" valign="top">

### πŸ”Ž Search & manage
- πŸ” Search Torget, real estate, rentals, used cars, jobs
- πŸ“„ Full listing details by FINN-kode
- πŸ”” Saved searches with "what's new since last time"
- πŸ“‹ **Mine annonser** β€” list your ads with state
- ⚑ **Ad actions** β€” run the actions finn.no offers per ad (delete, …), with confirmation
- πŸ” Passwordless login kept in a local browser profile

</td>
</tr>
</table>

---

## πŸ†š Why this project

Existing finn.no MCP servers are **read-only scrapers**. This one adds the whole seller side, built on finn.no's internal JSON API (mapped with an included recon tool) rather than brittle form clicking.

| | Read-only scrapers | **finn-seller-mcp** |
|---|:---:|:---:|
| Search & listing details | βœ… | βœ… |
| Saved searches with diff | some | βœ… |
| Log in to your account | ❌ | βœ… (you log in, never the AI) |
| Create / publish Torget ads | ❌ | βœ… |
| Photo upload + EXIF/GPS strip | ❌ | βœ… |
| Category suggestion from photos | ❌ | βœ… |
| Market-price suggestion | ❌ | βœ… |
| Duplicate protection (local + your finn.no ads) | ❌ | βœ… |
| Manage "Mine annonser" | ❌ | βœ… |
| Recon tool to re-map the site when it changes | ❌ | βœ… |

---

## πŸš€ Quick Start

**1. Install** (needs [uv](https://docs.astral.sh/uv/) and Python 3.11+)

```bash
git clone https://github.com/ml-senior/finn-seller-mcp
cd finn-seller-mcp
uv sync
uv run playwright install chromium
```

**2. Register with your MCP client**

<details open>
<summary><b>Claude Desktop</b></summary>

Edit `claude_desktop_config.json` and restart Claude Desktop:

| OS | Path |
|---|---|
| Linux | `~/.config/Claude/claude_desktop_config.json` |
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |

```json
{
  "mcpServers": {
    "finn": {
      "command": "uv",
      "args": ["--directory", "/absolute/path/to/finn-seller-mcp", "run", "finn-seller-mcp"]
    }
  }
}
```
</details>

<details>
<summary><b>Claude Code</b></summary>

```bash
claude mcp add finn -- uv --directory /absolute/path/to/finn-seller-mcp run finn-seller-mcp
```
</details>

<details>
<summary><b>Any other MCP client (stdio)</b></summary>

Command: `uv`, args: `--directory /absolute/path/to/finn-seller-mcp run finn-seller-mcp`. The server speaks MCP over stdio.
</details>

**3. Log in once**

Ask the assistant to run `login` (or run the recon script, below). A Chromium window opens on finn.no β€” **you** type your e-mail and the one-time code from Vend. The session is stored in a local browser profile and reused from then on.

**4. Sell something**

> "Sell this controller" + πŸ“Έ photo

That's it. The assistant checks for duplicates, prepares a private draft on finn.no, shows you the preview and publishes only after you say yes.

> [!IMPORTANT]
> finn.no asks for a one-time **BankID identity verification** before your first Torget ad goes live. If `publish_draft` returns `bankid_required`, open the `edit_url` it gives you, press **GΓ₯ videre β†’ Verifiser med BankID**, finish it yourself, then ask the assistant to publish again.

---

## πŸ’¬ Example session

```text
You:     Sell this. [photo of a PS5 controller]
Claude:  β–Έ check_duplicate      β†’ nothing similar on your account
         β–Έ suggest_price        β†’ median 500 kr from 14 comparable ads
         β–Έ create_draft         β†’ photo cleaned (EXIF/GPS removed)
         β–Έ push_draft           β†’ private draft created on finn.no
         β–Έ suggest_category     β†’ Elektronikk β€Ί Spill og konsoll β€Ί TilbehΓΈr
         Here's the ad:
           PS5 DualSense kontroller – hvit Β· Pent brukt Β· 500 kr Β· 1 photo
           "Original Sony DualSense trΓ₯dlΓΈs kontroller …"
         Publish?
You:     yes
Claude:  β–Έ publish_draft(confirm=true) β†’ live βœ…
```

---

## πŸ”„ How it works

```mermaid
flowchart LR
    U([You + photos]) --> A[Assistant]
    A -->|check_duplicate| D{Already listed?}
    D -- yes --> S[Stop and show matches]
    D -- no --> P[suggest_price<br/>median of comparables]
    P --> C[create_draft<br/>EXIF/GPS strip, resize]
    C --> PU[push_draft<br/>private draft on finn.no]
    PU --> SC[suggest_category<br/>finn.no prediction]
    SC --> PV[Preview to you]
    PV -->|your explicit yes| PB[publish_draft confirm=true]
    PB --> L([Live on finn.no])
```

Under the hood the server talks to the same JSON endpoints the finn.no web form uses, through a **persistent Playwright Chromium profile** so requests carry your logged-in session:

```mermaid
sequenceDiagram
    participant M as finn-seller-mcp
    participant F as finn.no
    M->>F: POST create-item … /api/create (adType=recommerce)
    F-->>M: 301 β†’ /recommerce/create/{adId}
    loop each photo
        M->>F: POST /recommerce/create/api/image/{adId} (multipart "image")
        F-->>M: { path: "item/{adId}/{uuid}", width, height }
    end
    M->>F: PUT /recommerce/create/api/item/{adId}  e-tag: W/"rev"  { commit:false, data }
    F-->>M: { state:"draft", violations:[], etag }
    M->>F: POST /recommerce/create/api/predictions/categories/{adId}
    Note over M: user confirms in chat
    M->>F: PUT /recommerce/create/api/item/{adId}  { commit:true, data }
    F-->>M: published β€” or { requiredVerification:true } β†’ BankID needed
```

<details>
<summary><b>The ad payload (confirmed shape)</b></summary>

```json
{
  "trade_type": "SELL",
  "category": 65,
  "condition": 3,
  "title": "PS5 DualSense kontroller – hvit",
  "description": "Original Sony DualSense …",
  "price": { "price_amount": 500 },
  "address": { "postalcode": "0150", "country": "NO" },
  "image": [ { "uri": "item/{adId}/{uuid}", "width": 960, "height": 1280, "description": "optional caption" } ]
}
```

Saves use optimistic locking: every `PUT` sends the last `etag` in an **`e-tag`** request header. No CSRF token is involved β€” only the session cookies plus `Origin`/`Referer`. Full notes: [RECON.md](RECON.md).
</details>

---

## 🧰 Tool reference

<details open>
<summary><b>πŸ” Session</b></summary>

| Tool | What it does |
|---|---|
| `login` | Opens a visible Chromium on finn.no so **you** log in (e-mail + one-time code). The assistant never handles credentials. |
| `login_status` | Checks whether the saved profile is logged in; returns how many ads the account has. |
</details>

<details open>
<summary><b>🏷️ Selling</b></summary>

| Tool | What it does | Touches finn.no? |
|---|---|:---:|
| `check_duplicate` | Title + photo match against local history **and** all your finn.no ads (active, sold, expired, drafts). | read |
| `suggest_price` | Median price of comparable "Til salgs" Torget ads; IQR outlier removal; rounded; returns example ads. | read |
| `create_draft` | Local draft. Photos auto-rotated, EXIF/GPS stripped, resized to ≀2400 px, HEIC supported. Reports possible duplicates. | β€” |
| `update_draft` | Change any field of a local draft (price, category, condition, …). | β€” |
| `list_drafts` / `preview_draft` | Inspect local drafts and the exact payload that would be sent. | β€” |
| `push_draft` | Create/update a **private** draft on finn.no, upload photos, return finn.no's own validation. Never publishes. | write (private) |
| `suggest_category` | finn.no's category prediction from the uploaded photos (leaf + full path). | read |
| `confirm_not_duplicate` | Unblock a draft the duplicate guard flagged β€” only after **you** say it's a different item. | β€” |
| `publish_draft` | Duplicate check β†’ push β†’ validation β†’ with `confirm=true` only: publish. Detects the BankID requirement. | **public** |
</details>

<details open>
<summary><b>πŸ“‹ Your ads</b></summary>

| Tool | What it does |
|---|---|
| `my_ads` | "Mine annonser": your ads with state, facets and the actions finn.no offers for each. |
| `ad_action` | Run one of those actions by name (e.g. `DELETE`). Anything that changes an ad needs `confirm=true`. |
</details>

<details>
<summary><b>πŸ”Ž Search (from the original read-only server)</b></summary>

| Tool | What it does |
|---|---|
| `search_finn` | Search `bap` (Torget), `homes`, `lettings`, `cars_used`, `jobs` with free text + finn.no URL filters. |
| `get_listing` | Full listing by FINN-kode (JSON-LD parsing, 24 h cache). |
| `save_search` / `list_saved_searches` / `delete_saved_search` | Persist named searches. |
| `check_saved_search` | Run a saved search and return only listings new since the last check. |
</details>

<details>
<summary><b>πŸ“š Code tables</b></summary>

**Condition** β€” `1` Helt ny Β· `2` Som ny Β· `3` Pent brukt Β· `4` Godt brukt Β· `5` MΓ₯ fikses

**Top-level Torget categories** β€” `76` Antikviteter og kunst Β· `77` Dyr og utstyr Β· `93` Elektronikk og hvitevarer Β· `68` Foreldre og barn Β· `86` Fritid, hobby og underholdning Β· `67` Hage, oppussing og hus Β· `71` KlΓ¦r, kosmetikk og tilbehΓΈr Β· `78` MΓΈbler og interiΓΈr Β· `91` NΓ¦ringsvirksomhet Β· `69` Sport og friluftsliv Β· `90` Utstyr til bil, bΓ₯t og MC

Ads need a **leaf** category id (e.g. `93 β€Ί 3905 β€Ί 65` β†’ use `65`). Easiest: `push_draft` with a best guess, then `suggest_category`.
</details>

---

## πŸ’° Pricing policy

| Situation | Price used |
|---|---|
| You name a price | **Exactly your price** |
| You don't | `suggest_price` β†’ **median** of comparable "Til salgs" ads, IQR outliers dropped, rounded (10 / 50 / 100 / 500 kr steps by size) |
| Fewer than 3 comparables | The assistant asks you instead of guessing |

The policy is written into the tool descriptions, so any MCP client follows it.

---

## πŸ” Duplicate protection

Before creating and again before publishing, every new ad is compared with:

- πŸ—ƒοΈ **Local history** β€” every draft/ad made through this server, photos matched by a 64-bit perceptual **dHash** (survives resizing, re-compression and EXIF stripping; ≀10-bit distance = same picture).
- 🌐 **Your finn.no account** β€” all ads in "Mine annonser" in any state, matched by normalized title similarity (β‰₯85 %, or β‰₯60 % plus a shared photo).

A hit **blocks** `publish_draft`. Only `confirm_not_duplicate`, called after you say "it's a different item", unblocks it.

---

## πŸ›‘οΈ Safety model

| Rule | How it's enforced |
|---|---|
| The AI never logs in for you | `login` opens a window; you type e-mail and code yourself. |
| Nothing goes public without your "yes" | `publish_draft` returns a preview unless `confirm=true`; tool docs require explicit user consent. |
| Destructive actions are gated | `ad_action` requires `confirm=true` for any non-GET action (delete, …). |
| No accidental re-listing | Duplicate guard blocks publishing. |
| Your photos don't leak location | EXIF (incl. GPS) is dropped; images re-encoded. |
| Identity checks stay yours | BankID is detected and handed back to you, never automated. |
| Recon can't publish by accident | The recon script aborts any request containing `"commit": true` and payment-like URLs. |

---

## 🧭 Recon: mapping the site yourself

finn.no can change its internals at any time. `scripts/recon.py` lets anyone re-map the seller flow in minutes **without publishing anything**:

```bash
uv run python scripts/recon.py --start https://www.finn.no/create-item/start
```

1. A Chromium window opens with the **same profile** the server uses β€” log in.
2. Click through the flows (new ad, photos, price, "Publiser", Mine annonser, edit…). Press the red **πŸ“Έ Snapshot form** button on each form step.
3. Close the window.

<details>
<summary><b>What gets recorded</b></summary>

| File | Content |
|---|---|
| `recon/<ts>/endpoints.md` | Endpoint map grouped by method + path template (IDs β†’ `{id}`), write calls first, β›” = blocked |
| `recon/<ts>/endpoints.json` | Same, machine-readable |
| `recon/<ts>/requests.jsonl` | Every API call: headers, bodies, status, responses β€” **redacted** (cookies, tokens, e-mails, phone numbers) |
| `recon/<ts>/bodies/` | Large bodies; multipart uploads summarized (field names, sizes β€” no file bytes) |
| `recon/<ts>/forms/` | Form snapshots: every input/select incl. shadow DOM, labels, options, selectors + full-page screenshot |
| `recon/<ts>/private/full.har` | Full HAR **with cookies β€” never share** |

Safety flags: `--block REGEX` adds patterns; `--no-default-block` disables blocking (real actions will happen!). Tracker hosts are filtered out. Non-UTF-8 request bodies are handled safely.
</details>

---

## βš™οΈ Configuration

| Variable | Default | Purpose |
|---|---|---|
| `FINN_PROFILE_DIR` | `$XDG_DATA_HOME/finn-mcp/browser-profile` | Persistent Chromium profile (your session) |
| `FINN_HEADLESS` | `1` | `0` shows the browser for all calls (debugging) |
| `FINN_CHROMIUM` | Playwright's Chromium | Path to a custom Chromium/Chrome binary |
| `XDG_DATA_HOME` | `~/.local/share` | Base for profile, drafts, cache |
| `FINN_CACHE_DB` | `…/finn-mcp/cache.sqlite` | Search/listing cache (24 h) |
| `FINN_BACKEND` | `scraper` | Search backend (`official` is a stub) |
| `FINN_BASE_URL` | `https://www.finn.no` | Override for tests against a mock server only |

Local data lives in `~/.local/share/finn-mcp/`: `browser-profile/`, `drafts/<id>/draft.json` + cleaned photos, `cache.sqlite`.

---

## πŸ—‚οΈ Project layout

```text
finn-seller-mcp/
β”œβ”€β”€ src/finn_mcp/
β”‚   β”œβ”€β”€ server.py            # FastMCP server, search tools, registers seller tools
β”‚   β”œβ”€β”€ scraper/             # read-only HTML/JSON-LD scrapers (bap, homes, cars, jobs)
β”‚   β”œβ”€β”€ backend/             # search backend abstraction
β”‚   └── seller/
β”‚       β”œβ”€β”€ tools.py         # seller MCP tools
β”‚       β”œβ”€β”€ api.py           # finn.no seller JSON API client (via Playwright context)
β”‚       β”œβ”€β”€ browser.py       # persistent Chromium session, interactive login
β”‚       β”œβ”€β”€ drafts.py        # local draft store + validation
β”‚       β”œβ”€β”€ photos.py        # EXIF/GPS strip, rotate, resize, HEIC
β”‚       β”œβ”€β”€ dupes.py         # perceptual-hash + title duplicate detection
β”‚       β”œβ”€β”€ pricing.py       # market-price median with outlier removal
β”‚       └── selectors.py     # every site-specific URL/selector in one place
β”œβ”€β”€ scripts/recon.py         # site recorder with publish blocking
β”œβ”€β”€ tests/                   # fixtures-based tests (no network)
β”œβ”€β”€ RECON.md                 # what we know about finn.no internals
└── docs/                    # translations, assets
```

---

## πŸ§ͺ Development & tests

```bash
uv sync
uv run pytest -q          # offline: HTML fixtures, pricing, duplicates
```

Tests never hit finn.no. For end-to-end work, point `FINN_BASE_URL` at a mock server that replays shapes from `RECON.md`.

---

## 🩺 Troubleshooting

<details>
<summary><b><code>not_logged_in</code> / session expired</b></summary>

Run the `login` tool (or the recon script) and log in again. Sessions are long-lived but finn.no expires them eventually.
</details>

<details>
<summary><b><code>bankid_required</code> on publish</b></summary>

One-time identity check by finn.no. Open the returned `edit_url`, press **GΓ₯ videre β†’ Verifiser med BankID**, complete it, then publish again.
</details>

<details>
<summary><b><code>finn_validation</code> with <code>VALID_MANAGED "Ugyldig verdi"</code> on category</b></summary>

You used a parent category. Use a **leaf** id β€” run `suggest_category` after `push_draft`.
</details>

<details>
<summary><b><code>possible_duplicate</code></b></summary>

Working as intended. Check the matches; if it really is a different item, tell the assistant so it calls `confirm_not_duplicate`.
</details>

<details>
<summary><b>Playwright: "your OS is not officially supported"</b></summary>

Harmless on Kali/Arch/etc. β€” Playwright downloads an Ubuntu build that works. Or set `FINN_CHROMIUM` to your own Chromium.
</details>

<details>
<summary><b>Recon login hangs</b></summary>

Update to the latest `scripts/recon.py` β€” older versions crashed on non-UTF-8 request bodies sent by the login page, which stalled the request.
</details>

---

## ❓ FAQ

**Is there an official finn.no API for this?**
Not for private sellers. FINN's import APIs are B2B (jobs, real estate, dealers) and require a contract. This project uses the same internal endpoints as the finn.no website, with your own session.

**Does the AI see my password?**
There is no password β€” login is e-mail + one-time code, and you type both yourself in a real browser window.

**Can it publish without asking?**
No. `publish_draft` without `confirm=true` only returns a preview, and the tool contract requires your explicit "yes".

**Which categories work?**
All Torget leaf categories. Category-specific extra fields can be passed via `attributes`.

**Does it work for cars / real estate?**
Search: yes. Selling: Torget only for now.

---

## πŸ—ΊοΈ Roadmap

- [x] Read-only search, listings, saved searches
- [x] Login via persistent browser profile
- [x] Create / push / publish Torget ads, photo upload
- [x] Category prediction, market price, duplicate guard
- [x] Mine annonser + generic ad actions
- [x] Recon tool with publish blocking
- [ ] "Gi bort" (give away) trade type
- [ ] Fiks ferdig (shipping) fields
- [ ] Mark sold / deactivate / renew on live ads (action names pending recon)
- [ ] Buyer messages
- [ ] Full category tree export
- [ ] Price from Klarna Prisguiden (new-price ceiling)

---

## 🀝 Contributing

PRs welcome β€” see [CONTRIBUTING.md](CONTRIBUTING.md). If finn.no changed something, run the recon script and attach the **redacted** `endpoints.md` (never the `private/` folder).

---

## βš–οΈ Disclaimer

Independent open-source project, **not affiliated with or endorsed by FINN.no / Vend Marketplaces**. It automates your own account through finn.no's web endpoints; you are responsible for complying with finn.no's terms of use. Keep usage personal and moderate β€” no bulk listing, no scraping at scale.

---

## πŸ“„ License & credits

[MIT](LICENSE) Β© 2026 **ml-senior**

Search/scraper layer based on [aHk-coder/finn-mcp](https://github.com/aHk-coder/finn-mcp) (MIT). Condition and category code tables adapted from [viktorfa/finn_mcp](https://github.com/viktorfa/finn_mcp) (MIT).

<div align="center">
<sub>Made for <a href="https://modelcontextprotocol.io/">MCP</a></sub>
</div>

TDQS

A3.6/5.0

Scored across 20 tools

Disambiguation4/5

Most tools target clearly distinct resources and lifecycle steps (drafting, publishing, ad actions, search, saved searches, auth). The only plausible confusion is between push_draft, publish_draft, and ad_action, but descriptions explicitly distinguish private upload vs public publish vs post-publish actions, so boundaries remain clear.

Naming Consistency4/5

The dominant pattern is verb_noun (list_drafts, preview_draft, create_draft, update_draft, push_draft, publish_draft, suggest_price, save_search, check_duplicate, search_finn, get_listing). A few deviations exist (ad_action, my_ads, login_status, bare-verb login), but they remain readable and contextually obvious.

Tool Count4/5

20 tools is on the heavy side, but the server genuinely spans several distinct capabilitiesβ€”auth, local drafting, publishing, marketplace search, and saved-search monitoringβ€”and no tool appears redundant. Slightly over what a single-purpose server would need, but reasonable for this scope.

Completeness4/5

The seller lifecycle is well covered: create/update/preview drafts, category and price suggestions, duplicate detection, private push, publish, and ad actions plus listing/browse/saved-search CRUD. Minor gaps exist (no single-draft getter, no buyer messaging, no explicit photo curation tool), but agents can work around them.

Maintenance

ActivityMaintained
ResponsivenessNo issues