finn-seller-mcp
<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.
[](https://www.python.org/)
[](https://modelcontextprotocol.io/)
[](https://playwright.dev/python/)
[](https://github.com/ml-senior/finn-seller-mcp/actions/workflows/test.yml)
[](LICENSE)
[](#-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
Scored across 20 tools
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.
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.
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.
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.