Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# Changelog

## 0.12.0

### `list_parcels_in_area` finds a building number on an official address point

- When a `buildingNumber` search comes back empty against the sale record, the same street and
number are now tried against official address points that fall inside the parcel. That match
ignores case on the number (`12a` finds `12A`) but is still exact — no compound splitting — and
matches the street by the same whole-word rule as `street=`. Only when both the record and the
address points miss does the page come back empty (with the tokens refunded and the street's
recorded numbers listed to retry).

- A row whose number came from an address point is tagged
`[number from an official address point on this parcel, not from a deed]`, and one from the
register `[number from a recorded sale deed]`, so a number worked out from address points is
never read as a deed number. `address_source` gains the value `address_point` alongside `rcn`,
`approx_high`, `approx_low` and `none`.

### `street=` now matches whole words, not any fragment

- `street=` matches whole words of the name instead of any substring: `Górna` finds
`ulica Górna` and `Górna 15` but no longer `Podgórna`, and a partial fragment like `Marsza`
now finds nothing. Pass the whole word. Matching stays case- and accent-insensitive and still does
not inflect (nominative only).

- The minimum `street=` length rises from 3 to 4 letters or digits. A three-letter fragment is a
single index gram that matches hundreds of thousands of names; four keeps the lookup fast. A
three-character query is now refused up front instead of being sent.

- When the street exists in the area but not with the number you gave, the answer says so and drops
the generic "widen the area" advice, which would have pointed away from the number.

These refine an existing call shape. `buildingNumber` behaviour is additive; the `street=`
whole-word rule and the 3→4 minimum are stricter than before, so a fragment or three-letter query
that used to match may now return an empty page — retry with the whole word.

## 0.11.0

### Accent- and case-tolerant street filter, with suggestions on a miss
Expand Down
20 changes: 10 additions & 10 deletions TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_
- `maxPrice` (number, optional) — Maximum price in PLN
- `dateFrom` (string, optional) — Start date (YYYY-MM-DD)
- `dateTo` (string, optional) — End date (YYYY-MM-DD)
- `street` (string, optional) — Street name filter, matched anywhere inside the name (e.g. 'Puławska', 'Trakt Lubelski'). Give it in the NOMINATIVE and with its Polish diacritics — matching is literal, so 'Karmelickiej' does not find 'Karmelicka' and 'Marszalkowska' does not find 'Marszałkowska'. Either mistake answers with nothing, which reads exactly like 'no such transactions'.
- `buildingNumber` (string, optional) — Building/house number (e.g. '251C', '12A'). Requires location or street to be set.
- `street` (string, optional) — Street name filter, matched anywhere inside the name (e.g. 'Puławska', 'Aleja Waszyngtona'). Give it in the NOMINATIVE and with its Polish diacritics — matching is literal, so 'Karmelickiej' does not find 'Karmelicka' and 'Marszalkowska' does not find 'Marszałkowska'. Either mistake answers with nothing, which reads exactly like 'no such transactions'.
- `buildingNumber` (string, optional) — Building/house number (e.g. '30', '12A'). Requires location or street to be set.
- `parcelId` (string, optional) — Exact parcel ID as returned in search results (e.g. '146518_8.0108.27'). Must match exactly - copy from a previous search result's parcel_id field.
- `minArea` (number, optional) — Minimum area in m²
- `maxArea` (number, optional) — Maximum area in m²
Expand Down Expand Up @@ -252,9 +252,9 @@ Truncation is the normal case on outlines, not an edge case. An area the size of

minArea / maxArea (m²) filter on the registered parcel surface without returning it. They need a narrow scope — a bbox, a circle, a location name, or a teryt of at least 4 digits (county level) — and are refused elsewhere with a message saying what to add. They are not available on the outline calls.

Every row of the light list carries the street address held for that parcel, whether or not you asked about one, and says where it came from: a street on record, or one we worked out for a parcel the record left without a street (shown with a note saying so). A building number is only ever on record, so a worked-out street never carries one. Most rural parcels have no street at all — for those the way in is resolve_parcel with the precinct name and the parcel number, not a street.
- street: matches the name case-insensitively anywhere inside it, 3 letters or digits minimum, 200 characters maximum. It is a NAME, not a pattern: % and _ match themselves. Both sources are searched at once and a parcel found in both appears once, attributed to the record. Give the name the way it is written on the street sign, in the NOMINATIVE and with its Polish diacritics: matching is literal, so 'Karmelickiej' does not find 'Karmelicka' and 'Marszalkowska' does not find 'Marszałkowska'. Either mistake answers with an empty list, which reads exactly like 'no such parcels here' — decline the name yourself before you pass it, and if a name you are sure of comes back empty, suspect the spelling before you conclude anything about the data.
- buildingNumber: matches EXACTLY ('12A' does not find '12a') and needs street alongside it. RCN often records a compound or split number ('84/92'), so an exact '84' will not find '84/92' — when a plain number comes back empty, the record may hold it in a compound form, so try the street on its own and read the numbers off the rows. Because a number is only ever on record, a call carrying one answers only with parcels whose street is on record.
Every row of the light list carries the street address held for that parcel, whether or not you asked about one, and says where it came from: a street on record, one we worked out for a parcel the record left without a street (shown with a note saying so), or — only when a buildingNumber search on the record came back empty — one read off an official address point that falls inside the parcel (also noted). A building number is only ever on record or from that address point, so a worked-out street never carries one. When a page comes back with a requested buildingNumber, it also carries a note on which of the two the number came from. If the street exists in the area but not with that number, the answer says so instead of the generic "no such street". Most rural parcels have no street at all — for those the way in is resolve_parcel with the precinct name and the parcel number, not a street.
- street: matches whole words of the name, case- and accent-insensitively — 4 letters or digits minimum, 200 characters maximum. It is a NAME, not a pattern: % and _ match themselves. 'Górna' finds 'ulica Górna' and 'Górna 15' but not 'Podgórna', and a fragment like 'Marsza' finds nothing. Both sources are searched at once and a parcel found in both appears once, attributed to the record. Matching folds accents, so 'karmelicka' finds 'Karmelicka' — but it does NOT inflect: pass the name in the NOMINATIVE, because an inflected form like 'Karmelickiej' answers with an empty list. That empty page costs nothing (the tokens are refunded) and carries a suggestions block with close names to retry, so read the suggestions before you conclude anything about the data.
- buildingNumber: needs street alongside it. Against the record the match is EXACT ('12A' does not find '12a'); RCN often records a compound or split number ('84/92'), so an exact '84' will not find '84/92'. When that comes back empty, the same street+number is tried against official address points instead — that match ignores case ('12a' finds '12A') but is still exact on the number, no compound splitting, and it matches the street by the same whole-word rule as above, not a fragment ('Waszyngtona' finds 'Aleja Waszyngtona', 'szyng' does not). Only when BOTH miss does the page come back empty (the tokens are refunded), listing the numbers held on the street as suggestions to retry.
Both need a narrow scope for the same reason minArea does — a bbox, a circle, a teryt of at least 4 digits, or a location name — and neither counts as naming the area: a common street name across the country is a national search, not a question. Neither is available on the outline calls.

Parcel identity is gated: parcel_id comes back for API-token / OAuth callers and for paid or active-trial accounts, and is withheld for everyone else while the location and the outline still come back.
Expand All @@ -269,16 +269,16 @@ Limits: an area over 500 km², a radius over 12.6 km (the same ground) or a poly
**Parameters:**

- `teryt` (string, optional) — TERYT administrative code prefix (2/4/6 digits or finer), comma-separated for several. Wins over location when both are given.
- `location` (string, optional) — County or city name, resolved to its TERYT code. An ambiguous name is an error listing the candidates.
- `location` (string, optional) — County, city, or district name, resolved to its TERYT code. A Warszawa district name (e.g. 'Mokotów') narrows to that district; another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. An ambiguous name is an error listing the candidates.
- `bbox` (string, optional) — Bounding box in WGS84 as "minLng,minLat,maxLng,maxLat", covering at most 500 km². Required for includeGeometry=true.
- `lat` (number, optional) — Latitude of the circle centre (WGS84). Requires lng and radiusKm.
- `lng` (number, optional) — Longitude of the circle centre (WGS84). Requires lat and radiusKm.
- `radiusKm` (number, optional) — Circle radius in km (max 12.6 — the radius covering the 500 km² ceiling). Requires lat and lng.
- `polygon` (effects, optional) — GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs, first and last point identical, at most 500 vertices. Always returns outlines. Pass it instead of the other area parameters, not alongside them.
- `minArea` (number, optional) — Minimum parcel surface in m². Needs a bbox, a circle, a location name, or a teryt of at least 4 digits. Not available with includeGeometry or a polygon.
- `maxArea` (number, optional) — Maximum parcel surface in m². Same scope requirement as minArea.
- `street` (string, optional) — Street name, matched case-insensitively anywhere inside the name (min 3 letters or digits). A name, not a pattern — % and _ match themselves. NOMINATIVE and with Polish diacritics: 'Karmelickiej' and 'Marszalkowska' both answer empty. Needs a bbox, a circle, a location name, or a teryt of at least 4 digits. Not available with includeGeometry or a polygon.
- `buildingNumber` (string, optional) — Building number, matched exactly ('12A' does not find '12a'). RCN numbering is often compound ('84/92'), so an exact '84' misses '84/92' — if a plain number answers empty, drop it and read the numbers off the street's rows. Requires street, and answers only with parcels whose street is on record. Same scope requirement as street.
- `street` (string, optional) — Street name, matched by whole words within the name, case- and accent-insensitively (min 4 letters or digits): 'Górna' finds 'ulica Górna' and 'Górna 15' but not 'Podgórna', and a fragment like 'Marsza' finds nothing. NOMINATIVE: 'Karmelickiej' does not fold to 'Karmelicka' and answers empty. Needs a bbox, a circle, a location name, or a teryt of at least 4 digits. Not available with includeGeometry or a polygon.
- `buildingNumber` (string, optional) — Building number. Against the record the match is exact ('12A' does not find '12a') and RCN numbering is often compound ('84/92'), so an exact '84' misses '84/92'. When that comes back empty, the same number is tried against official address points instead, case-insensitively ('12a' finds '12A') but still exact, matching the street by the same whole-word rule as street, not a fragment — if that also misses, drop the number and read the numbers off the street's rows. Requires street. Same scope requirement as street.
- `includeGeometry` (boolean, optional) — Return each parcel's full outline instead of the light row (5 tokens instead of 2, at most 100 parcels, no paging). Requires a bbox; with a polygon the outlines come back anyway.
- `limit` (number, optional) — Max parcels returned. Light list: up to 1000, default 250. Outlines: up to 100, default 50 — a larger value is clamped there.
- `cursor` (string, optional) — Opaque cursor from the previous light-list answer, to get the next page. Pass it back unchanged; it is not available on outline calls.
Expand Down Expand Up @@ -368,7 +368,7 @@ A query returns the requested level PLUS all parent levels (a gmina query also y

**Parameters:**

- `location` (string, optional) — City/county name, resolves to county/powiat level (e.g. 'Warszawa', 'Kraków'). Use this OR teryt. For gmina-level data pass a 6/7-digit teryt instead.
- `location` (string, optional) — City, county, or district name (e.g. 'Warszawa', 'Kraków'). A city/county name resolves to county/powiat level; a Warszawa district name (e.g. 'Mokotów') resolves to that district, another city's delegatura (e.g. 'Kraków-Podgórze') to its parent county. Use this OR teryt. For gmina-level data on other units pass a 6/7-digit teryt instead.
- `teryt` (string, optional) — TERYT code: 2-digit (voivodeship, e.g. 14), 4-digit (county, e.g. 1465), 6 or 7-digit (gmina, e.g. 1465011). The 7th digit selects the unit type: 3 = urban-rural gmina overall, 4/5 = urban/rural part only, 8 = Warszawa district (1465011 = all of Warszawa, 1465108 = Śródmieście). Wins over location. Use list_locations to find codes.
- `year` (number, optional) — Single year (2003-present). Mutually exclusive with yearFrom/yearTo. Omit for the latest available year per indicator.
- `yearFrom` (number, optional) — Start year for a time series (min 2003).
Expand All @@ -387,7 +387,7 @@ Cost: 1 token.

**Parameters:**

- `location` (string, optional) — City or county name (e.g. 'Warszawa', 'Krotoszyn'). Aggregates every municipality in the county. Use this OR teryt.
- `location` (string, optional) — City, county, or district name (e.g. 'Warszawa', 'Krotoszyn'). A city/county name aggregates every municipality in the county; a Warszawa district name (e.g. 'Mokotów') narrows to that gmina, another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. Use this OR teryt.
- `teryt` (string, optional) — TERYT code: 6 or 7 digits = one municipality (e.g. 146501), 4 digits = a county aggregate (e.g. 1465). Wins over location.

---
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cenogram/mcp-server",
"version": "0.11.0",
"version": "0.12.0",
"description": "MCP server for Polish real estate: 8M+ transaction prices from notarial deeds (RCN registry, not listings), plus per-parcel context by cadastral ID - zoning, flood risk, heritage, building permits, agricultural land, land use and soil class, transit, nature, mining terrains and groundwater reservoirs",
"type": "module",
"bin": {
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "pl.cenogram/mcp-server",
"title": "Cenogram - Polish Real Estate Prices & Parcel Data",
"description": "Polish real estate for AI: 8M+ deed transaction prices (RCN) + per-parcel context by cadastral ID",
"version": "0.11.0",
"version": "0.12.0",
"websiteUrl": "https://cenogram.pl/en/mcp?src=mcpregistry",
"repository": {
"url": "https://github.com/cenogram/mcp-server",
Expand All @@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "@cenogram/mcp-server",
"version": "0.11.0",
"version": "0.12.0",
"transport": {
"type": "stdio"
},
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ describe("api-client", () => {
const { getTransactions } = await import("../api-client.js");
await getTransactions({
district: "Wawer",
street: "Trakt Lubelski",
buildingNumber: "251C",
street: "Aleja Waszyngtona",
buildingNumber: "30",
parcelId: "146518_8.0108.27",
});

const url = mockFetch.mock.calls[0]![0] as string;
expect(url).toContain("street=Trakt");
expect(url).toContain("buildingNumber=251C");
expect(url).toContain("street=Aleja");
expect(url).toContain("buildingNumber=30");
expect(url).toContain("parcelId=146518");
});

Expand Down Expand Up @@ -620,8 +620,8 @@ describe("api-client", () => {
const { getTransactionsSummary } = await import("../api-client.js");
await getTransactionsSummary({
district: "Warszawa",
street: "Trakt Lubelski",
buildingNumber: "251C",
street: "Aleja Waszyngtona",
buildingNumber: "30",
parcelId: "146518_8.0108.27",
floodRisk: "high",
heritageStatus: "listed",
Expand All @@ -630,7 +630,7 @@ describe("api-client", () => {
const url = mockFetch.mock.calls[0]![0] as string;
// Drift guard: summary must carry the same row-filtering params as getTransactions,
// otherwise "Found N" reports an unfiltered total.
expect(url).toContain("buildingNumber=251C");
expect(url).toContain("buildingNumber=30");
expect(url).toContain("parcelId=146518_8.0108.27");
expect(url).toContain("floodRisk=high");
expect(url).toContain("heritageStatus=listed");
Expand Down
Loading
Loading