Skip to main content
Glama
cvele

Polovnjak MCP

by cvele
README.md
# Polovnjak MCP

A multi-provider [MCP](https://modelcontextprotocol.io) server for used-vehicle marketplaces. Agents get one unified tool surface — search, listing details, price statistics, price history, and per-user saved-search watches — backed by pluggable marketplace adapters. Included adapters: [polovniautomobili.com](https://www.polovniautomobili.com) (source id `pa`) and [mojauto.rs](https://www.mojauto.rs) (source id `ma`), both Serbia.

Users sign in with Google via the standard MCP OAuth 2.1 flow; watches are private per user. Every listing the hub observes (from any user's searches, stats sampling, or watch checks) is recorded into a shared Postgres "market memory" that powers price history and trend queries no single marketplace offers.

## Architecture

- **Adapters** (`src/adapters/`) implement a common `SourceAdapter` interface: `search`, `getListing`, `getMakesModels`, plus capability flags. Each adapter translates the canonical filters into its provider's query and maps results back into a canonical listing model. Listings carry composite ids like `pa:12345678` or `ma:3554254`. Make names are alias-resolved per source ("VW" and "Volkswagen" both work everywhere); filters a source cannot express are reported as per-source `warnings` in results.
- **Tools** (`src/tools/`) fan out across adapters and merge results, tagging everything with its source. Categories cover cars, motorcycles, vans, light/heavy trucks, buses, trailers, campers, machinery, tractors, boats, and bicycles.
- **Recorder** (`src/recorder.ts`) upserts every observed listing into `listings` and appends a `price_observations` row on first sighting or price change.
- **Auth** (`src/auth/`) is a self-hosted OAuth 2.1 authorization server (dynamic client registration, PKCE, refresh tokens) with Google OIDC as the identity layer. Tokens are stored hashed in Postgres.
- **Storage** is Postgres via Drizzle ORM; migrations run automatically at startup.

## Quick start (Docker Compose)

1. Create a Google OAuth client at [Google Cloud Console](https://console.cloud.google.com/apis/credentials) (type "Web application") with authorized redirect URI `<PUBLIC_URL>/auth/google/callback` (e.g. `http://localhost:8321/auth/google/callback` for local use).
2. Configure the environment:

```bash
cp .env.example .env
# set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (and PUBLIC_URL for non-local deployments)
```

3. Start everything (MCP server + Postgres + FlareSolverr):

```bash
docker compose up -d --build
```

4. Point your MCP client at `http://localhost:8321/mcp`. The client will discover the OAuth metadata, register itself, and open a browser window for Google sign-in. Everything after that is per-user.

For a quick local test without Google credentials, set `AUTH_MODE=none` — the server then skips auth and uses a single local dev user.

## Tools

| Tool | Purpose |
| --- | --- |
| `list_sources` | Registered marketplace sources with coverage and capability flags. Source ids are used in the `sources` param of other tools and in composite listing ids. |
| `search_listings` | One page of normalized listings per source, merged and source-tagged. Filters: category, make/models, price/year/mileage/power ranges, fuel, body, gearbox, region/city, condition flags. Listings include `previousPrice` when the source shows a discount; per-source `warnings` explain any filter a source could not apply natively. |
| `get_listing` | Full detail for one listing by composite id (`pa:12345678`, `ma:3554254`), URL, or bare id: specs, equipment, description, photos, seller contact. |
| `get_makes_and_models` | Per-source taxonomy: makes, models of a make, regions, accepted filter values, city-name resolution. |
| `get_price_stats` | Live market snapshot: min/p25/median/avg/p75/max + per-year breakdown, per source and combined, sampled from up to `maxPages` result pages per source. |
| `get_price_history` | Answered from the hub's own recorded observations: a single listing's price timeline (repricing detection) or a month-by-month trend for a segment (make/model/year). Coverage grows with usage. |
| `create_watch` / `list_watches` / `delete_watch` / `check_watches` | Per-user saved searches across one or more sources. `check_watches` returns only listings that appeared since the last check — ideal for a scheduled daily prompt. |

## Agent skill

The repo ships an agent skill at [.cursor/skills/car-market-hub/SKILL.md](.cursor/skills/car-market-hub/SKILL.md) that teaches LLM agents how to use this server effectively (tool workflows, gotchas), with per-client connection guides for OpenAI, Claude, LibreChat, and Cursor in [clients.md](.cursor/skills/car-market-hub/clients.md). Cursor picks it up automatically; for other platforms, copy the skill directory into that platform's skills location (e.g. `.claude/skills/`) or paste its contents into the agent's instructions.

## Watch workflow

1. `create_watch` with a name and filters (optionally restricted to specific `sources`) — current matches become the baseline.
2. Later (e.g. a scheduled prompt: "check my car watches"), `check_watches` returns only the listings that appeared since, per watch and per source.

## Adding a new marketplace adapter

1. Create `src/adapters/<name>/` and implement the `SourceAdapter` interface from [src/adapters/types.ts](src/adapters/types.ts): pick a short stable `id` (used in composite listing ids), translate canonical `SearchQuery` filters into your provider's query, and map raw results into the canonical `ListingSummary` / `ListingDetail` shapes from [src/domain/listing.ts](src/domain/listing.ts).
2. Register it in [src/server.ts](src/server.ts) with `registerAdapter(...)`.

Search fan-out, watches, recording, and price history pick the new source up automatically.

## Environment variables

| Variable | Default | Meaning |
| --- | --- | --- |
| `PORT` | `8080` | HTTP listen port. |
| `PUBLIC_URL` | `http://localhost:<PORT>` | Public base URL; OAuth issuer and Google callback host. |
| `DATABASE_URL` | `postgres://carhub:carhub@localhost:5432/carhub` | Postgres connection string. |
| `AUTH_MODE` | `oauth` | `oauth` (Google-backed OAuth 2.1) or `none` (single local dev user, no auth). |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | — | Google OAuth credentials (required in `oauth` mode). |
| `FLARESOLVERR_URL` | — | Optional FlareSolverr instance for Cloudflare-challenge fallback. |
| `CACHE_TTL_MS` | `600000` | Upstream response cache TTL. |
| `FETCH_CONCURRENCY` | `5` | Max concurrent upstream requests per adapter. |
| `REQUEST_TIMEOUT_MS` | `25000` | Upstream request timeout. |

## Local development

```bash
npm install
docker compose up -d postgres        # or point DATABASE_URL at your own Postgres
AUTH_MODE=none npm run dev           # server on http://localhost:8080/mcp
```

Schema changes: edit [src/db/schema.ts](src/db/schema.ts), then `npm run db:generate` to produce a new migration under `drizzle/` (applied automatically at startup).

## Notes and limits

- polovniautomobili.com has no public API; the adapter reads the site's Next.js data endpoints using Chrome TLS impersonation (impit), with FlareSolverr as Cloudflare-challenge fallback. Use responsibly and respect the site's terms of service.
- Prices below 100 EUR are treated as "price on request" and excluded from stats and observations.
- Watch checks scan the first two result pages (~50 newest listings) per source.
- `get_price_history` reflects only listings observed through this hub — it is a growing sample, not the full market.
- The previous single-provider implementation is kept for reference in `old/` (not part of the build).