Skip to main content
Glama
lambda-house

korter-mcp

by lambda-house
README.md
# korter-mcp

Event-sourced [korter.ge](https://korter.ge) proxy exposed as MCP tools.
Tbilisi new-builds, with **price history** — korter shows you a price, this
shows you how it moved.

Built on [`@lambda-house/teob-ts`](https://github.com/lambda-house/teob-ts).
Personal research tool. Not a product, not a data service.

## Registering in Claude Code

Local stdio server (fetches korter itself, keeps its own history):

```bash
pnpm install && pnpm run build
claude mcp add --transport stdio korter -- node /path/to/korter-mcp/dist/main.js stdio
```

Project-scoped: this repo ships [`.mcp.json`](.mcp.json), so a Claude Code
session opened in this directory has the server automatically.

Remote (a deployed `serve` instance does the fetching; one shared history, no
duplicate load on korter):

```bash
# MCP over Streamable HTTP (put an OAuth-capable gateway in front — see Modes):
claude mcp add --transport http korter https://<your-host>/mcp

# or the stdio facade over the deployed read API:
KORTER_MODE=remote \
KORTER_REMOTE_URL=https://<your-host> \
KORTER_REMOTE_TOKEN=<api token> \
  claude mcp add --transport stdio korter -- node /path/to/korter-mcp/dist/main.js stdio
```

Once connected, call the `get_skill` tool (`apartment-search`,
`criteria-coverage`, `freshness-and-history`, `operating-limits`) — the server
ships its own usage guidance.

## Tools

Reads (served from projections and the journal — never from a live fetch):
`search_projects`, `get_project`, `list_districts`, `price_history`,
`diff_report`.

Commands: `refresh` (fetch a source now — refreshing is an action, not a
`force` flag on reads), `track` / `untrack` / `list_tracked` (the weekly sweep
set, journaled).

Source ids are `project:<slug>` or `listing:<slug>`, e.g.
`project:tsavkisi-park-tbilisi`, `listing:new-projects-in-avlabari`.

**Every result carries the envelope**: `source_url`, `fetched_at`,
`prices_as_of` (korter's own "prices up to date as of" date — null when korter
shows none), `observed_at`, `staleness_days`. All timestamps come from journal
events, never from the clock at serialization time; `staleness_days` is the
one field allowed to consult the clock, because "how stale is this now" is a
question about now.

## Why event sourcing for a scraper

Two reasons, and if either stopped being true a `sqlite3` + `httpx` script would
be the better tool:

1. **Price history is an event log.** `PriceObserved` on a `Project` entity *is*
   the history — no `price_history` table, no INSERT-if-changed rule. Dedup is a
   pure `decide` function returning `done()`.
2. **The same code runs two ways** — a local stdio MCP server on a laptop, and a
   long-lived prod service whose history keeps accruing whether or not the
   laptop is on.

## Design

Three aggregates:

| Aggregate | Per | Holds |
|---|---|---|
| `Project` | korter slug | attributes and the price history |
| `Source` | fetched URL | fetch lifecycle, listing membership |
| `Tracker` | singleton | the tracked set and the sweep schedule |

Raw HTML is *not* event-sourced — it lives in a plain 24h `page_cache` table,
with `sourceHash` on the events tying an observation back to its bytes. The
same goes for korter's district taxonomy: reference data in a plain table,
refreshed by listing fetches, not history.

Recon findings and the extraction map: [`docs/schema-notes.md`](docs/schema-notes.md).

### Storage

SQLite everywhere, including a deployed instance (on a persistent volume).
teob-ts's whole read model — projection store, live category tails, journal
reader — is SQLite-native, and this corpus is a handful of entities with
weekly writes. Postgres would be an unused dependency.

### Fixtures

`fixtures/` holds saved korter pages for parser-compatibility tests. They are
korter's content and are **not distributed** — absent (as in the public
snapshot), those tests skip and the suite still passes against the invented
pages in `test/synthetic.ts`. See [`fixtures/README.md`](fixtures/README.md).

## Modes

```bash
node dist/main.js stdio   # MCP over stdin/stdout (default; stdout carries frames, logs go to stderr)
node dist/main.js serve   # long-lived service: /api + /mcp on :8080, probes on :9095
node dist/main.js sweep   # one-shot sweep of the tracked set, prints the diff report, exits
pnpm run smoke            # 2 live requests against korter, excluded from CI
```

`serve` refuses to start without `KORTER_API_TOKEN` and
`KORTER_POMERIUM_TOKEN` — the endpoints must be authenticated, or a personal
research tool becomes a public scraper fronting someone else's site.

## Development

```bash
pnpm install
pnpm test               # zero network — everything runs against fixtures/
pnpm run typecheck
pnpm run build
```

## How it behaves toward korter

- **1 request/second**, globally, across every caller. Cache-first, 24h TTL.
  Weekly sweeps of a short tracked list.
- **Honest User-Agent**: `korter-mcp/0.1 (personal research tool)`.
- **403 or 429 stops the tool.** The breaker opens and stays open. Nothing
  rotates, nothing retries behind your back, nothing gets bypassed.
- Listing and project pages only — no secondary market, no user listings.
- No republication, no resale, no bulk export.
- `robots.txt` is checked before fetching (and korter's disallows their own
  hydration API — so only the HTML pages are read, never `/api/`).

These constraints live in `src/config.ts` and are asserted by the test suite.

## Known limitations

- **korter's own data goes stale.** Cards dated June 2025 were still displayed
  in August 2026 (the smoke run on 2026-08-30 found a live card 39 days stale).
  Read `prices_as_of` and `staleness_days`.
- **The parser is the fragile part.** One pure function, `src/parse/extract.ts`,
  reading `window.INITIAL_STATE`; fully fixture-tested. It will break when
  korter redesigns, and everything else is deliberately independent of it.
- **Districts are korter's taxonomy.** Their "Vake district" swallows Bagebi,
  Lisi and the Nutsubidze plateau. No mapping to colloquial neighbourhoods is
  invented.
- **Prices come in the page currency** (`/en/` serves USD). GEL and USD are
  independent series, never converted.
- **A page's currency history starts when this tool first observes it** — there
  is no backfill; korter shows no history to backfill from. That asymmetry is
  the reason this tool exists.
- korter's ToS likely prohibits automated extraction. The operator accepts that
  risk knowingly and keeps volume trivial in exchange. If you run this
  yourself, that trade-off — and the structural limits that keep it honest —
  becomes yours.

## License

[Apache-2.0](LICENSE).

TDQS

B3.2/5.0

Scored across 15 tools

Disambiguation4/5

Most tools target distinct resources or actions: search_projects vs search_secondary cover different markets, get_project vs price_history differ in scope, and track/untrack/refresh have clear boundaries. Minor potential confusion between diff_report/price_history/secondary_trends (all change-oriented) and between refresh and the track/untrack sweep tools, but descriptions clarify.

Naming Consistency3/5

Names use consistent snake_case, but conventions are mixed: some are verb_noun (get_project, list_districts, search_projects), others are bare verbs/nouns (track, refresh, consent), and others are noun phrases (diff_report, price_history, my_data). Readable, but not a predictable single pattern.

Tool Count4/5

At 15 tools, the set is at the upper end of the ideal range but each tool maps to a concrete capability (search, track, refresh, history, secondary market, consent/privacy, skills). It is slightly heavy due to auxiliary tools (get_skill, consent, my_data, list_users) but not bloated or mismatched for the domain.

Completeness4/5

Core lifecycle for the domain is covered: source tracking (track/untrack/list_tracked), manual refresh, project search and detail, history/diff, secondary search and trends, plus consent and user data. Minor gaps exist, such as no explicit bulk source management or a per-source metadata view, but agents can work around them.

Maintenance

ActivityMaintained
ResponsivenessNo issues