diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b31622e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +dist +*.log +.git +.gitignore +.github +.claude +.env +.env.* +README.md +CONTRIBUTING.md +SECURITY.md +LICENSE +server.json +src/__tests__ +src/**/*.test.ts +eslint.config.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 966234a..6660af6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,8 +16,9 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - run: npm ci - - run: npm test + # Build first: part of the suite only runs against dist/, and skips itself when it is absent. - run: npm run build + - run: npm test - name: Verify tag matches package version run: | PKG_VERSION=$(node -p "require('./package.json').version") diff --git a/.gitignore b/.gitignore index 637df6a..3afad7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ node_modules/ dist/ *.tsbuildinfo +# The leak-guard skips node_modules/, dist/, coverage/ and .turbo/ on the strength of the rules in +# THIS file — the four rules above and below, wherever they sit. Remove one and the guard keeps +# skipping the directory while git starts committing it. A test enforces the pairing; heed it. +coverage/ +.turbo/ +# Holds absolute paths of the machine that ran the lint — must not travel with this directory. +.eslintcache .env .env.* !.env.example diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bd91520 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +## 0.5.0 + +First release since 0.2.0, so it carries everything published on the hosted server in the +meantime. Versions 0.3.0 and 0.4.0 exist in the source history but were never released to npm. + +### New tools (14) + +Parcels: +- `resolve_parcel` - resolve a cadastral parcel identifier to its canonical record +- `get_parcel_report` - composite dossier for one parcel: core data, enrichment layers, + transaction history, local price context and municipal context + +Context for a location: +- `get_demographics` - population and demographic context +- `get_infrastructure_signals` - municipal infrastructure signals (tenders, utilities, + capital spending) +- `estimate_value` - comparable-sales value estimate for a property (Beta) + +Context for the property behind a single transaction, each taking a `transaction_id` from a +search result: +- `get_building_breakdown` - per-building footprint, storeys, estimated floor area +- `get_transaction_flood` - flood risk +- `get_transaction_heritage` - heritage-register status +- `get_transaction_landslide` - landslide risk +- `get_transaction_surroundings` - nuisance and land-use context around the property +- `get_transaction_transit` - public transport accessibility +- `get_transaction_permits` - building permits recorded for the property +- `get_transaction_planning` - local zoning and planning status +- `get_transaction_farmland` - agricultural land-use classification + +### Changed + +- Search filters: floor (for units), ownership type, and an explicit "no data" option where a + field can be missing. +- Results carry parcel identifiers and coordinates consistently, so a search can be followed by + a parcel or enrichment lookup without a second search. +- All calls now go to the versioned `/api/v1` endpoints. +- Tool descriptions state how Warsaw and Krakow districts are addressed, and a wrong location + name now comes back with a usable correction instead of a bare 404. + +### Fixed - error messages an AI agent can act on + +- `Retry-After` was read as days instead of seconds, so a five-second rate limit was reported as + "resets in 1 day". It now reports seconds, minutes or hours, and says nothing about time at + all when the server did not send a usable value. +- Every payment-required response was reported as "insufficient credits (balance: 0)" even when + the account had a full balance and the real cause was an expired trial. The server's own + explanation is now relayed. +- Running without an API key was reported as an internal error with an invitation to file a bug, + and pointed at a page behind a login. It now says a key is missing and where to get one. +- 403, 503 and 410 responses relayed no detail. They now carry the server's explanation, and 410 + states that the endpoint is gone for good rather than suggesting a retry. + +## 0.2.0 + +- Authentication header fix, English error messages. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d6cf93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci + +COPY tsconfig*.json ./ +COPY src ./src +RUN npm run build + +FROM node:22-alpine +WORKDIR /app + +COPY --from=build /app/package*.json ./ +COPY --from=build /app/dist ./dist +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev + +ARG GIT_SHA=unknown +ENV GIT_SHA=$GIT_SHA +ENV NODE_ENV=production +ENV MCP_TRANSPORT=http +ENV MCP_PORT=3002 + +USER node +EXPOSE 3002 + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 4719c59..bde8f38 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ MCP server for Polish real estate data. Access 8M+ real estate transactions from the national Registry of Prices and Values (Rejestr Cen Nieruchomosci, RCN) directly from Claude, Cursor, or any MCP-compatible AI assistant. -Data source: Polish national RCN registry (Rejestr Cen Nieruchomosci) | Platform: [cenogram.pl](https://cenogram.pl) +Data source: Polish national RCN registry (Rejestr Cen Nieruchomosci) | Platform: [cenogram.pl](https://cenogram.pl?src=mcpstdio) ## Get your API key -1. Go to [cenogram.pl/api](https://cenogram.pl/api) +1. Go to [cenogram.pl/api](https://cenogram.pl/api?src=mcpstdio) 2. Enter your email 3. You'll receive your `cngrm_...` API key by email @@ -176,7 +176,7 @@ Requires **Node.js >= 18**. Use this if you want to run the server locally inste | Env Variable | Required | Default | Description | |---|---|---|---| -| `CENOGRAM_API_KEY` | **Yes** (stdio) | - | API key from [cenogram.pl/api](https://cenogram.pl/api) | +| `CENOGRAM_API_KEY` | **Yes** (stdio) | - | API key from [cenogram.pl/api](https://cenogram.pl/api?src=mcpstdio) | | `CENOGRAM_API_URL` | No | `https://cenogram.pl` | API base URL | | `MCP_TRANSPORT` | No | `stdio` | Set to `http` for Streamable HTTP mode | | `MCP_PORT` | No | `3002` | HTTP server port (HTTP mode only) | @@ -184,6 +184,10 @@ Requires **Node.js >= 18**. Use this if you want to run the server locally inste You can also use the `--http` CLI flag instead of `MCP_TRANSPORT=http`. +## Tips + +- **Model selection**: For best results, use Claude **Opus 4.7**. It makes more sequential tool calls and produces richer analysis. You can switch the model in the dropdown at the bottom of the chat window. + ## Example Prompts **Polish:** @@ -215,12 +219,27 @@ You can also use the `--http` CLI flag instead of `MCP_TRANSPORT=http`. | `search_parcels` | Search parcels by cadastral ID prefix | q (parcel ID prefix, min 3 chars) | | `search_by_polygon` | Search within a GeoJSON polygon | polygon, propertyType, dateFrom/dateTo | | `compare_locations` | Compare stats across 2-5 districts | districts (comma-separated), propertyType | +| `get_building_breakdown` | Per-building breakdown for one transaction (footprint, storeys, est. floor area) | transaction_id (UUID from a search result) | +| `get_parcel_report` | Composite dossier for one parcel: core, 9 enrichment layers, transaction history, local price context and municipal context | parcelId (cadastral id or UUID) | +| `resolve_parcel` | Resolve a cadastral parcel identifier to its canonical record | parcelId or q (id prefix), or lat + lng | +| `get_demographics` | Population and demographic context for a location | location or teryt, year (or yearFrom/yearTo), category | +| `get_infrastructure_signals` | Municipal infrastructure signals (tenders, utilities, capital spending) | location or teryt | +| `estimate_value` | Comparable-sales value estimate for a property | area, plus lat + lng or parcelId; rooms, market | +| `get_transaction_flood` | Flood risk for the property in a transaction | transaction_id (UUID from a search result) | +| `get_transaction_heritage` | Heritage-register status for the property | transaction_id | +| `get_transaction_landslide` | Landslide risk for the property | transaction_id | +| `get_transaction_surroundings` | Nuisance and land-use context around the property | transaction_id | +| `get_transaction_transit` | Public transport accessibility for the property | transaction_id | +| `get_transaction_permits` | Building permits recorded for the property | transaction_id | +| `get_transaction_planning` | Local zoning and planning status for the property | transaction_id | +| `get_transaction_farmland` | Agricultural land-use classification for the property | transaction_id | ### Location naming - Most cities: use the city name directly (e.g., "Gdansk", "Lublin") -- Warsaw: use district names ("Mokotow", "Srodmiescie", "Wola") -- "Warszawa" won't match -- Krakow: use sub-districts ("Krakow-Podgorze", "Krakow-Srodmiescie") - plain "Krakow" won't match +- Warsaw: "Warszawa" covers all 18 districts at once; name one ("Mokotow", "Srodmiescie", "Wola") to narrow it down +- Krakow and Lodz work the same way: the city name covers every sub-district, or name one ("Krakow-Podgorze") +- Neighbourhood names are not administrative units - search by radius or polygon instead - Use `list_locations` to find valid names ### Property types @@ -258,7 +277,7 @@ This mimics how a property appraiser finds comparable transactions for valuation **npx hangs or fails** - Check your Node.js version with `node -v`. The stdio mode requires Node.js >= 18. If you're on an older version, use the HTTP remote option instead (no Node.js needed). -**"Warszawa" returns 0 results** - Warsaw uses district names (Mokotow, Wola, Srodmiescie, Bemowo, etc.). Use `list_locations(search="warsz")` to find valid names. Same applies to Krakow (use "Krakow-Podgorze", "Krakow-Srodmiescie", etc.). +**A location returns 0 results** - The name may not be an administrative unit. Districts and neighbourhoods are two different things: "Mokotow" is a district and works, "Sluzew" is a neighbourhood inside it and does not. Use `list_locations(search="...")` to find valid names, or search by radius (`search_by_area`) for anything smaller than a district. **401 Unauthorized (HTTP mode)** - The `Authorization` header must be `Bearer cngrm_...` (with the `Bearer` prefix). Double-check that the full API key is included, not just the prefix. diff --git a/eslint.config.js b/eslint.config.js index ec4c028..8f9b508 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,7 @@ import js from '@eslint/js' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'vitest.config.ts'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.ts'], diff --git a/package-lock.json b/package-lock.json index ef4b905..9d90454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@cenogram/mcp-server", - "version": "0.2.0", + "version": "0.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cenogram/mcp-server", - "version": "0.2.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", + "@sentry/node": "^10.54.0", "jose": "^6.2.3", "undici": "^5.29.0", "zod": "^3.24.0" @@ -741,6 +742,94 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.124.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", @@ -998,6 +1087,91 @@ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true }, + "node_modules/@sentry/core": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.54.0.tgz", + "integrity": "sha512-yC/bc8N5ut6vk9X/ugTnIFAbzaSZ2uGoKiHRGzt7VseDIrjXk5ENDJP0m7Rbchuozr41kBv2QB3mPcHUhfB43w==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.54.0.tgz", + "integrity": "sha512-Jc31dMBs9aBUv6TXmIPNwv2u18YbfvWQG32IkM3dFWAAoJQhCqLZfN0MEDSf9TeNexIf8qBMZtJRHgHIrWYiGg==", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/core": "10.54.0", + "@sentry/node-core": "10.54.0", + "@sentry/opentelemetry": "10.54.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.54.0.tgz", + "integrity": "sha512-QR5RnIK78g0Np2+VWMZ3TatM7C+oX9zIQ1W36o3KOjw0nNcXkWjZT1lEu4me8cp2s8s3hA4qT7fwcciQqkj1UQ==", + "dependencies": { + "@sentry/core": "10.54.0", + "@sentry/opentelemetry": "10.54.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.54.0.tgz", + "integrity": "sha512-58Jk9yMos5DwhamDsNmnoQMSNx0yD9E+h1pZwkw34ve2qB9tv+cys3Oz6nfazT9ZdIsXIgpQntN8AfMXAvv4/g==", + "dependencies": { + "@sentry/core": "10.54.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1206,7 +1380,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -1214,6 +1387,14 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1392,6 +1573,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2279,6 +2465,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2745,6 +2945,11 @@ "node": "*" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3057,6 +3262,18 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4425,6 +4642,61 @@ "@tybys/wasm-util": "^0.10.1" } }, + "@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" + }, + "@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "requires": { + "@opentelemetry/api": "^1.3.0" + } + }, + "@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "requires": { + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "requires": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + } + }, + "@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "requires": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "requires": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==" + }, "@oxc-project/types": { "version": "0.124.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", @@ -4547,6 +4819,45 @@ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true }, + "@sentry/core": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.54.0.tgz", + "integrity": "sha512-yC/bc8N5ut6vk9X/ugTnIFAbzaSZ2uGoKiHRGzt7VseDIrjXk5ENDJP0m7Rbchuozr41kBv2QB3mPcHUhfB43w==" + }, + "@sentry/node": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.54.0.tgz", + "integrity": "sha512-Jc31dMBs9aBUv6TXmIPNwv2u18YbfvWQG32IkM3dFWAAoJQhCqLZfN0MEDSf9TeNexIf8qBMZtJRHgHIrWYiGg==", + "requires": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/core": "10.54.0", + "@sentry/node-core": "10.54.0", + "@sentry/opentelemetry": "10.54.0", + "import-in-the-middle": "^3.0.0" + } + }, + "@sentry/node-core": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.54.0.tgz", + "integrity": "sha512-QR5RnIK78g0Np2+VWMZ3TatM7C+oX9zIQ1W36o3KOjw0nNcXkWjZT1lEu4me8cp2s8s3hA4qT7fwcciQqkj1UQ==", + "requires": { + "@sentry/core": "10.54.0", + "@sentry/opentelemetry": "10.54.0", + "import-in-the-middle": "^3.0.0" + } + }, + "@sentry/opentelemetry": { + "version": "10.54.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.54.0.tgz", + "integrity": "sha512-58Jk9yMos5DwhamDsNmnoQMSNx0yD9E+h1pZwkw34ve2qB9tv+cys3Oz6nfazT9ZdIsXIgpQntN8AfMXAvv4/g==", + "requires": { + "@sentry/core": "10.54.0" + } + }, "@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4708,8 +5019,13 @@ "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==" + }, + "acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "requires": {} }, "acorn-jsx": { "version": "5.3.2", @@ -4835,6 +5151,11 @@ "supports-color": "^7.1.0" } }, + "cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5441,6 +5762,17 @@ "resolve-from": "^4.0.0" } }, + "import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", + "requires": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5700,6 +6032,11 @@ "brace-expansion": "^1.1.7" } }, + "module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5900,6 +6237,15 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "requires": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + } + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index 824f1a9..a52ce88 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@cenogram/mcp-server", - "version": "0.2.0", - "description": "MCP Server for Polish real estate transaction data (8M+ transactions from RCN)", + "version": "0.5.0", + "description": "MCP server for Polish property prices from notarial deeds, not listings - 8M+ transactions from the RCN registry", "type": "module", "bin": { "cenogram-mcp": "./dist/index.js" @@ -16,10 +16,11 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "check": "tsc --noEmit && eslint .", - "prepublishOnly": "npm run lint && npm run test && npm run build" + "prepublishOnly": "npm run lint && npm run build && npm run test" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", + "@sentry/node": "^10.54.0", "jose": "^6.2.3", "undici": "^5.29.0", "zod": "^3.24.0" @@ -36,12 +37,19 @@ "mcpName": "pl.cenogram/mcp-server", "keywords": [ "mcp", - "real-estate", "poland", - "rcn", - "nieruchomosci", + "europe", + "government-data", + "official-registry", + "public-records", + "real-estate", "property", - "transactions" + "property-data", + "transactions", + "transaction-data", + "geospatial", + "rcn", + "nieruchomosci" ], "license": "MIT", "publishConfig": { diff --git a/scripts/test-tool.mjs b/scripts/test-tool.mjs new file mode 100644 index 0000000..f92cda6 --- /dev/null +++ b/scripts/test-tool.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Driver: spawns local MCP server over stdio and calls a tool. +// Usage: +// CENOGRAM_API_KEY=cngrm_xxx node scripts/test-tool.mjs '' +// +// Optional: +// CENOGRAM_API_URL=http://localhost:3001 (default https://cenogram.pl) +// CENOGRAM_API_KEY=invalid_key (to test 401 path) +// +// Prints JSON-RPC result (or error) to stdout, exits 0 on transport success +// (tool errors arrive as { isError: true, content: [...] } per MCP spec). + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverPath = resolve(__dirname, "..", "dist", "index.js"); + +const [toolName, argsJson] = process.argv.slice(2); +if (!toolName) { + console.error("Usage: node scripts/test-tool.mjs ''"); + process.exit(2); +} + +let args = {}; +if (argsJson) { + try { + args = JSON.parse(argsJson); + } catch (e) { + console.error("Bad JSON args:", e.message); + process.exit(2); + } +} + +const transport = new StdioClientTransport({ + command: "node", + args: [serverPath], + env: { ...process.env }, +}); + +const client = new Client( + { name: "test-driver", version: "1.0.0" }, + { capabilities: {} }, +); + +try { + await client.connect(transport); + const result = await client.callTool({ name: toolName, arguments: args }); + console.log(JSON.stringify(result, null, 2)); + await client.close(); + process.exit(0); +} catch (err) { + console.log( + JSON.stringify( + { + _transportError: true, + name: err?.name, + message: err?.message, + code: err?.code, + data: err?.data, + }, + null, + 2, + ), + ); + try { + await client.close(); + } catch {} + process.exit(0); +} diff --git a/server.json b/server.json index 20d4133..df89d1d 100644 --- a/server.json +++ b/server.json @@ -2,8 +2,8 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "pl.cenogram/mcp-server", "title": "Cenogram - Polish Real Estate Data", - "description": "8M+ real estate transactions from Poland's RCN registry. Search, compare, and analyze prices.", - "version": "0.2.0", + "description": "Polish property prices from notarial deeds, not listings - 8M+ transactions from the RCN registry.", + "version": "0.5.0", "websiteUrl": "https://cenogram.pl", "repository": { "url": "https://github.com/cenogram/mcp-server", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "@cenogram/mcp-server", - "version": "0.2.0", + "version": "0.5.0", "transport": { "type": "stdio" }, diff --git a/src/__tests__/adversarial.test.ts b/src/__tests__/adversarial.test.ts new file mode 100644 index 0000000..c128b3d --- /dev/null +++ b/src/__tests__/adversarial.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDistrict, + buildNormalizedMap, + stripDiacritics, + CITY_SUBDISTRICTS, +} from "../mappings.js"; + +const SAMPLE_DISTRICTS = [ + "Kraków-Podgórze", + "Kraków-Krowodrza", + "Kraków-Nowa Huta", + "Kraków-Śródmieście", + "Warszawa", + "Mokotów", + "Wola", + "Poznań", + "Gdańsk", +]; + +describe("resolveDistrict — adversarial inputs", () => { + // ─── 1. Empty/null inputs ─────────────────────────────────────────── + describe("empty/null inputs", () => { + it("empty string returns passthrough", () => { + const result = resolveDistrict("", SAMPLE_DISTRICTS); + expect(result).toEqual([""]); + }); + + it("whitespace-only string returns passthrough (not matched)", () => { + const result = resolveDistrict(" ", SAMPLE_DISTRICTS); + expect(result).toEqual([" "]); + }); + + it("tab and newline string returns passthrough", () => { + const result = resolveDistrict("\t\n", SAMPLE_DISTRICTS); + expect(result).toEqual(["\t\n"]); + }); + }); + + // ─── 2. Very long strings ────────────────────────────────────────── + describe("very long strings", () => { + it("10000 char input does not throw", () => { + const longInput = "a".repeat(10000); + expect(() => resolveDistrict(longInput, SAMPLE_DISTRICTS)).not.toThrow(); + }); + + it("10000 char input returns passthrough", () => { + const longInput = "Kraków" + "x".repeat(10000); + const result = resolveDistrict(longInput, SAMPLE_DISTRICTS); + expect(result).toEqual([longInput]); + }); + + it("allDistricts with 10000 entries does not throw", () => { + const manyDistricts = Array.from({ length: 10000 }, (_, i) => `District-${i}`); + expect(() => resolveDistrict("District-5000", manyDistricts)).not.toThrow(); + const result = resolveDistrict("District-5000", manyDistricts); + expect(result).toEqual(["District-5000"]); + }); + }); + + // ─── 3. Special characters ───────────────────────────────────────── + describe("special characters", () => { + it("regex metacharacters do not throw", () => { + const metachars = ".*+?[]{}()\\^$|"; + expect(() => resolveDistrict(metachars, SAMPLE_DISTRICTS)).not.toThrow(); + const result = resolveDistrict(metachars, SAMPLE_DISTRICTS); + expect(result).toEqual([metachars]); // passthrough + }); + + it("SQL injection attempt is passthrough", () => { + const sqli = "'; DROP TABLE districts; --"; + const result = resolveDistrict(sqli, SAMPLE_DISTRICTS); + expect(result).toEqual([sqli]); + }); + + it("backticks and template literals", () => { + const input = "`${process.exit()}`"; + expect(() => resolveDistrict(input, SAMPLE_DISTRICTS)).not.toThrow(); + const result = resolveDistrict(input, SAMPLE_DISTRICTS); + expect(result).toEqual([input]); + }); + + it("null bytes in string", () => { + const input = "Kraków\0injected"; + expect(() => resolveDistrict(input, SAMPLE_DISTRICTS)).not.toThrow(); + }); + }); + + // ─── 4. Unicode edge cases ───────────────────────────────────────── + describe("unicode edge cases", () => { + it("Cyrillic homoglyph К does NOT match Latin K in Kraków", () => { + // Cyrillic К (U+041A) + Latin rest + const cyrillicK = "Кraków"; + const result = resolveDistrict(cyrillicK, SAMPLE_DISTRICTS); + // Should NOT resolve to Kraków sub-districts (Cyrillic К ≠ Latin K) + expect(result).not.toEqual(expect.arrayContaining(["Kraków-Podgórze"])); + }); + + it("full-width characters do NOT match normal Kraków", () => { + // Full-width Kraków + const fullWidth = "Kraków"; + const result = resolveDistrict(fullWidth, SAMPLE_DISTRICTS); + expect(result).not.toEqual(expect.arrayContaining(["Kraków-Podgórze"])); + }); + + it("zero-width joiners between letters do NOT match", () => { + // K + ZWJ + r + ZWJ + a + k + ó + w + const zwjInput = "K‍r‍a‍ków"; + const result = resolveDistrict(zwjInput, SAMPLE_DISTRICTS); + // ZWJ should prevent match since stripDiacritics only removes combining marks + expect(result).toEqual([zwjInput]); + }); + + it("zero-width non-joiner between letters", () => { + const zwnjInput = "K‌rakow"; + const result = resolveDistrict(zwnjInput, SAMPLE_DISTRICTS); + expect(result).toEqual([zwnjInput]); + }); + + it("combining characters stacked heavily", () => { + // K with multiple combining accents: K + combining acute + combining grave + combining tilde + const stacked = "Ḱ̀̃rakow"; + const result = resolveDistrict(stacked, SAMPLE_DISTRICTS); + // After NFD + strip combining marks, this should become "Krakow" → matches "krakow" → Kraków + expect(result).toEqual(expect.arrayContaining(["Kraków-Podgórze"])); + }); + + it("NFD vs NFC forms resolve identically", () => { + // NFC: Kraków (ó as single codepoint U+00F3) + const nfc = "Kraków"; + // NFD: Kraków (o + combining acute U+0301) + const nfd = "Kraków"; + + const resultNfc = resolveDistrict(nfc, SAMPLE_DISTRICTS); + const resultNfd = resolveDistrict(nfd, SAMPLE_DISTRICTS); + + expect(resultNfc).toEqual(resultNfd); + }); + + it("stripDiacritics handles NFD and NFC consistently", () => { + const nfc = "Kraków"; + const nfd = "Kraków"; + expect(stripDiacritics(nfc)).toBe(stripDiacritics(nfd)); + }); + + it("right-to-left override does not crash", () => { + const rtl = "‮Kraków"; + expect(() => resolveDistrict(rtl, SAMPLE_DISTRICTS)).not.toThrow(); + }); + }); + + // ─── 5. Collision exploitation ───────────────────────────────────── + describe("collision exploitation", () => { + it("input normalizing to 'krakow' resolves to city sub-districts", () => { + // "Krakow" (no accent) should match CITY_SUBDISTRICTS for Kraków + const result = resolveDistrict("Krakow", SAMPLE_DISTRICTS); + const expected = CITY_SUBDISTRICTS.get("Kraków")!.slice(); + expect(result).toEqual(expected); + }); + + it("allDistricts with duplicates returns all of them", () => { + const dupeDistricts = ["Poznań", "Poznań", "Gdańsk"]; + const map = buildNormalizedMap(dupeDistricts); + const poznans = map.get("poznan"); + expect(poznans).toEqual(["Poznań", "Poznań"]); + }); + + it("district whose normalized form collides with city name", () => { + // If someone adds a district literally named "krakow" (no diacritics) + const weirdDistricts = ["krakow", "Poznań"]; + const result = resolveDistrict("krakow", weirdDistricts); + // CITY_SUBDISTRICTS takes precedence (checked first in the function) + const expected = CITY_SUBDISTRICTS.get("Kraków")!.slice(); + expect(result).toEqual(expected); + }); + }); + + // ─── 6. CITY_SUBDISTRICTS boundaries ────────────────────────────── + describe("CITY_SUBDISTRICTS boundaries", () => { + it("sub-district name directly does NOT expand to full city", () => { + const result = resolveDistrict("Kraków-Podgórze", SAMPLE_DISTRICTS); + expect(result).toEqual(["Kraków-Podgórze"]); + }); + + it("city name with trailing space DOES match (trimmed)", () => { + const result = resolveDistrict("Kraków ", SAMPLE_DISTRICTS); + expect(result).toHaveLength(5); + expect(result).toContain("Kraków"); + }); + + it("city name with prefix does NOT match", () => { + const result = resolveDistrict("XXKraków", SAMPLE_DISTRICTS); + expect(result).toEqual(["XXKraków"]); + }); + + it("city name with suffix does NOT match", () => { + const result = resolveDistrict("Kraków-extra", SAMPLE_DISTRICTS); + // Not in CITY_SUBDISTRICTS keys, not in allDistricts → passthrough + expect(result).not.toEqual(CITY_SUBDISTRICTS.get("Kraków")!.slice()); + }); + + it("exact city name expands correctly (Warszawa)", () => { + const result = resolveDistrict("Warszawa", SAMPLE_DISTRICTS); + const expected = CITY_SUBDISTRICTS.get("Warszawa")!.slice(); + expect(result).toEqual(expected); + }); + + it("case-insensitive city match works (KRAKÓW → expansion)", () => { + const result = resolveDistrict("KRAKÓW", SAMPLE_DISTRICTS); + const expected = CITY_SUBDISTRICTS.get("Kraków")!.slice(); + expect(result).toEqual(expected); + }); + + it("diacritics-insensitive city match works (Lodz → Łódź expansion)", () => { + const result = resolveDistrict("Lodz", []); + const expected = CITY_SUBDISTRICTS.get("Łódź")!.slice(); + expect(result).toEqual(expected); + }); + }); + + // ─── 7. Memoization stress ───────────────────────────────────────── + describe("memoization / cache invalidation", () => { + it("different allDistricts arrays produce different results", () => { + const districts1 = ["Poznań", "Wrocław"]; + const districts2 = ["Gdańsk", "Sopot"]; + + const result1 = resolveDistrict("Poznań", districts1); + expect(result1).toEqual(["Poznań"]); + + const result2 = resolveDistrict("Poznań", districts2); + // Poznań not in districts2 and not a CITY_SUBDISTRICTS key → passthrough + expect(result2).toEqual(["Poznań"]); + }); + + it("same reference array uses cache (no rebuild)", () => { + const districts = ["TestDistrict"]; + // Call twice with same reference — should hit cache + const result1 = resolveDistrict("TestDistrict", districts); + const result2 = resolveDistrict("TestDistrict", districts); + expect(result1).toEqual(result2); + expect(result1).toEqual(["TestDistrict"]); + }); + + it("mutating the array after first call does NOT invalidate cache (reference equality)", () => { + const districts = ["Alpha", "Beta"]; + resolveDistrict("Alpha", districts); + + // Mutate the array + districts.push("Gamma"); + + // Same reference → cache NOT invalidated → Gamma not found + const result = resolveDistrict("Gamma", districts); + expect(result).toEqual(["Gamma"]); // passthrough (not in cached map) + }); + + it("new array with same contents invalidates cache", () => { + const districts1 = ["Alpha", "Beta"]; + resolveDistrict("Alpha", districts1); + + // New array (different reference) with same contents + const districts2 = ["Alpha", "Beta"]; + const result = resolveDistrict("Alpha", districts2); + expect(result).toEqual(["Alpha"]); + }); + }); +}); + +describe("stripDiacritics — adversarial", () => { + it("handles empty string", () => { + expect(stripDiacritics("")).toBe(""); + }); + + it("handles ł and Ł", () => { + expect(stripDiacritics("Łódź")).toBe("Lodz"); + }); + + it("handles all Polish diacritics", () => { + expect(stripDiacritics("ąćęłńóśźż")).toBe("acelnoszz"); + expect(stripDiacritics("ĄĆĘŁŃÓŚŹŻ")).toBe("ACELNOSZZ"); + }); + + it("preserves non-diacritic characters", () => { + expect(stripDiacritics("abc123!@#")).toBe("abc123!@#"); + }); + + it("handles string with only combining marks", () => { + // combining acute + combining grave + const input = "́̀"; + expect(() => stripDiacritics(input)).not.toThrow(); + expect(stripDiacritics(input)).toBe(""); + }); +}); + +describe("stripDiacritics — regex boundary", () => { + it("U+036F (last in range) IS stripped", () => { + // U+036F = COMBINING LATIN SMALL LETTER X + const input = "Kͯrakow"; + expect(stripDiacritics(input)).toBe("Krakow"); + }); + + it("U+0370 (Greek capital letter Heta, just outside range) is NOT stripped", () => { + // U+0370 is a letter, not a combining mark — should be preserved + const input = "KͰrakow"; + expect(stripDiacritics(input)).not.toBe("Krakow"); + expect(stripDiacritics(input)).toContain("Ͱ"); + }); + + it("combining marks outside U+0300-036F range are NOT stripped (U+0483)", () => { + // U+0483 = COMBINING CYRILLIC TITLO + const input = "K҃rakow"; + const result = stripDiacritics(input); + // The combining mark is preserved because it's outside the regex range + expect(result).toContain("҃"); + expect(result).not.toBe("Krakow"); + }); +}); + +describe("buildNormalizedMap — adversarial", () => { + it("empty districts array still has CITY_SUBDISTRICTS entries", () => { + const map = buildNormalizedMap([]); + expect(map.has("warszawa")).toBe(true); + expect(map.has("krakow")).toBe(true); + expect(map.has("lodz")).toBe(true); + }); + + it("district matching a city name key gets merged", () => { + const map = buildNormalizedMap(["Kraków"]); + const entry = map.get("krakow"); + // Should have "Kraków" from the districts array + expect(entry).toContain("Kraków"); + }); + + it("handles districts with identical normalized forms", () => { + const map = buildNormalizedMap(["Café", "Café"]); + // Both normalize to "cafe" after NFD + strip + const entry = map.get("cafe"); + expect(entry).toHaveLength(2); + }); +}); diff --git a/src/__tests__/api-client.test.ts b/src/__tests__/api-client.test.ts index 45c721e..767a587 100644 --- a/src/__tests__/api-client.test.ts +++ b/src/__tests__/api-client.test.ts @@ -26,7 +26,7 @@ describe("api-client", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/stats"); + expect(url).toContain("/api/v1/stats"); }); it("getTransactions passes query params", async () => { @@ -71,6 +71,221 @@ describe("api-client", () => { expect(url).toContain("parcelId=146518"); }); + it("getTransactions passes floodRisk filter", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], pagination: {}, summary: null }), + }); + + const { getTransactions } = await import("../api-client.js"); + await getTransactions({ district: "Wrocław", floodRisk: "medium,high" }); + + const url = mockFetch.mock.calls[0]![0] as string; + // comma may be URL-encoded (%2C) or literal depending on the serializer — match both, anchored to + // the floodRisk key so a stray "high" elsewhere can't satisfy the assertion. + expect(url).toMatch(/floodRisk=medium(%2C|,)high/); + }); + + it("getTransactionFlood hits the per-transaction flood endpoint", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], truncated: false }), + }); + + const { getTransactionFlood } = await import("../api-client.js"); + await getTransactionFlood("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/flood"); + }); + + it("getTransactions passes heritageStatus filter", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], pagination: {}, summary: null }), + }); + + const { getTransactions } = await import("../api-client.js"); + await getTransactions({ district: "Toruń", heritageStatus: "listed,zone" }); + + const url = mockFetch.mock.calls[0]![0] as string; + // comma may be URL-encoded (%2C) or literal depending on the serializer — match both, anchored to + // the heritageStatus key so a stray "zone" elsewhere can't satisfy the assertion. + expect(url).toMatch(/heritageStatus=listed(%2C|,)zone/); + }); + + it("getTransactions passes landslideRisk filter", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], pagination: {}, summary: null }), + }); + + const { getTransactions } = await import("../api-client.js"); + await getTransactions({ district: "Gdańsk", landslideRisk: "landslide,threatened" }); + + const url = mockFetch.mock.calls[0]![0] as string; + // comma may be URL-encoded (%2C) or literal depending on the serializer — match both, anchored to + // the landslideRisk key so a stray "threatened" elsewhere can't satisfy the assertion. + expect(url).toMatch(/landslideRisk=landslide(%2C|,)threatened/); + }); + + it("getTransactions passes ownershipType filter", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], pagination: {}, summary: null }), + }); + + const { getTransactions } = await import("../api-client.js"); + // CSV as produced by mapOwnershipTypes(["land_ownership","perpetual_usufruct"]) → "1,2,8". + await getTransactions({ district: "Poznań", ownershipType: "1,2,8" }); + + const url = mockFetch.mock.calls[0]![0] as string; + // comma may be URL-encoded (%2C) or literal — anchor to the ownershipType key. + expect(url).toMatch(/ownershipType=1(%2C|,)2(%2C|,)8/); + }); + + it("getTransactionHeritage hits the per-transaction heritage endpoint", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], truncated: false }), + }); + + const { getTransactionHeritage } = await import("../api-client.js"); + await getTransactionHeritage("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/heritage"); + }); + + it("getTransactionLandslide hits the per-transaction landslide endpoint and passes the body through", async () => { + const body = { + data: [{ + landslide_risk: "landslide", severity_rank: 1, pct_in_zone: "80.00", + zones: [{ kind: "landslide", source_version_date: "2021-03-15" }], + }], + truncated: false, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); + + const { getTransactionLandslide } = await import("../api-client.js"); + const { data } = await getTransactionLandslide("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/landslide"); + expect(data).toEqual(body); + }); + + it("getTransactionLandslide passes an empty two-state body through untouched", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], truncated: false }), + }); + + const { getTransactionLandslide } = await import("../api-client.js"); + const { data } = await getTransactionLandslide("11111111-2222-3333-4444-555555555555"); + + expect(data).toEqual({ data: [], truncated: false }); + }); + + it("getTransactionSurroundings hits the per-transaction surroundings endpoint", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], truncated: false }), + }); + + const { getTransactionSurroundings } = await import("../api-client.js"); + await getTransactionSurroundings("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/surroundings"); + }); + + it("getTransactionPermits hits the per-transaction permits endpoint", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [], truncated: false, note: "…" }), + }); + + const { getTransactionPermits } = await import("../api-client.js"); + await getTransactionPermits("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/permits"); + }); + + it("getTransactionPlanning hits the per-transaction planning endpoint and passes the three-state body through", async () => { + const body = { + data: [{ + kind: "zone", zone_symbol: "SW", zone_name: "multi-family residential zone", + pct_of_parcel: 80, max_building_height_m: 12, max_development_intensity: 1.2, + max_built_up_coverage_pct: 40, min_bio_active_area_pct: 30, + params_mixed: false, effective_from: "2025-12-03", + }], + truncated: false, coverage: "covered", parcels_total: 1, parcels_covered: 1, note: null, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); + + const { getTransactionPlanning } = await import("../api-client.js"); + const { data } = await getTransactionPlanning("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/planning"); + expect(data).toEqual(body); + }); + + it("getTransactionPlanning passes an empty not_covered body through untouched", async () => { + const body = { data: [], truncated: false, coverage: "not_covered", parcels_total: 1, parcels_covered: 0, note: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); + + const { getTransactionPlanning } = await import("../api-client.js"); + const { data } = await getTransactionPlanning("11111111-2222-3333-4444-555555555555"); + + expect(data).toEqual(body); + }); + + it("getTransactionFarmland hits the per-transaction farmland endpoint and passes the envelope through", async () => { + const body = { + data: [{ eligible_area_m2: 3984, pct_of_parcel: 87, feature_count: 2 }], + truncated: false, + parcels_total: 1, + parcels_with_data: 1, + as_of: "2026-07-01", + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); + + const { getTransactionFarmland } = await import("../api-client.js"); + const { data } = await getTransactionFarmland("11111111-2222-3333-4444-555555555555"); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/transactions/11111111-2222-3333-4444-555555555555/farmland"); + expect(data).toEqual(body); + }); + + it("getTransactionFarmland passes an empty two-state envelope through untouched", async () => { + const body = { data: [], truncated: false, parcels_total: 0, parcels_with_data: 0, as_of: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); + + const { getTransactionFarmland } = await import("../api-client.js"); + const { data } = await getTransactionFarmland("11111111-2222-3333-4444-555555555555"); + + expect(data).toEqual(body); + }); + it("throws readable message on non-200 status (500)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -128,15 +343,53 @@ describe("api-client", () => { await expect(fetchApi("/api/stats")).rejects.toThrow("Too many requests"); }); - it("429 error includes days from Retry-After", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 429, - headers: { get: (h: string) => h === "Retry-After" ? "259200" : null }, + // A 429 from this API means "wait a few seconds", so the message has to say seconds. + // It used to divide Retry-After by 86400 and report every bounce as "Resets in 1 day(s)", + // which read to a calling agent as "the allowance is gone, come back tomorrow". + describe("429 Retry-After", () => { + const bounce = async (retryAfter: string | null) => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + headers: { get: (h: string) => (h === "Retry-After" ? retryAfter : null) }, + }); + const { fetchApi } = await import("../api-client.js"); + return fetchApi("/api/stats").then( + () => { throw new Error("expected a rejection"); }, + (err: Error) => err.message, + ); + }; + + it.each([ + ["5", "Retry in 5 seconds."], + ["1", "Retry in 1 second."], + ["60", "Retry in 1 minute."], + ["90", "Retry in 2 minutes."], + ["3600", "Retry in 1 hour."], + ["259200", "Retry in 3 days."], + ["0", "Retry in 1 second."], + ])("Retry-After: %s -> %s", async (header, expected) => { + expect(await bounce(header)).toContain(expected); }); - const { fetchApi } = await import("../api-client.js"); - await expect(fetchApi("/api/stats")).rejects.toThrow("Resets in 3 day(s)"); + it("says it is a rate limit, not an exhausted allowance", async () => { + expect(await bounce("5")).toContain("rate limit, not an exhausted allowance"); + }); + + it.each([[null], ["", ], ["soon"], ["-5"]])( + "stays silent about timing for an unusable header (%s)", + async (header) => { + const message = await bounce(header as string | null); + expect(message).toContain("Retry shortly."); + expect(message).not.toContain("NaN"); + expect(message).not.toMatch(/day/); + }, + ); + + it("reads the RFC 7231 HTTP-date form a proxy may send", async () => { + const in90s = new Date(Date.now() + 90_000).toUTCString(); + expect(await bounce(in90s)).toMatch(/Retry in (1|2) minutes?\./); + }); }); it("throws on timeout", async () => { @@ -173,7 +426,7 @@ describe("api-client", () => { expect(result.data).toHaveLength(1); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/price-per-m2"); + expect(url).toContain("/api/v1/price-per-m2"); }); it("getDistricts calls correct endpoint", async () => { @@ -187,7 +440,36 @@ describe("api-client", () => { expect(result.data).toEqual(["Mokotów", "Śródmieście"]); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/districts"); + expect(url).toContain("/api/v1/districts"); + }); + + it("getRentalYield passes location to the rental-yield endpoint", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ location: "Warszawa", gross_yield_pct: 5.5 }), + }); + + const { getRentalYield } = await import("../api-client.js"); + await getRentalYield({ location: "Warszawa" }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/rental-yield"); + expect(url).toContain("location=Warszawa"); + }); + + it("getRentalYield passes teryt (and omits empty location)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ location: "Kraków", gross_yield_pct: 4.9 }), + }); + + const { getRentalYield } = await import("../api-client.js"); + await getRentalYield({ teryt: "1261" }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/rental-yield"); + expect(url).toContain("teryt=1261"); + expect(url).not.toContain("location="); }); it("returns creditInfo when response headers present", async () => { @@ -251,12 +533,71 @@ describe("api-client", () => { await getTransactionsSummary({ district: "Mokotów", propertyType: 4, dateFrom: "2024-01-01" }); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/transactions/summary"); + expect(url).toContain("/api/v1/transactions/summary"); expect(url).toContain("district=Mokot"); expect(url).toContain("propertyType=4"); expect(url).toContain("dateFrom=2024-01-01"); }); + it("getTransactionsSummary forwards floodRisk, heritageStatus, buildingNumber, parcelId (count must match filtered rows)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ median_price_m2: 15000, avg_area: 55, total: 100 }), + }); + + const { getTransactionsSummary } = await import("../api-client.js"); + await getTransactionsSummary({ + district: "Warszawa", + street: "Trakt Lubelski", + buildingNumber: "251C", + parcelId: "146518_8.0108.27", + floodRisk: "high", + heritageStatus: "listed", + }); + + 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("parcelId=146518_8.0108.27"); + expect(url).toContain("floodRisk=high"); + expect(url).toContain("heritageStatus=listed"); + }); + + it("getTransactionsSummary forwards landslideRisk (count must match filtered rows)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ median_price_m2: 15000, avg_area: 55, total: 100 }), + }); + + const { getTransactionsSummary } = await import("../api-client.js"); + await getTransactionsSummary({ + district: "Gdynia", + landslideRisk: "landslide", + }); + + const url = mockFetch.mock.calls[0]![0] as string; + // Same drift guard as floodRisk: a summary that drops the filter reports an unfiltered total. + expect(url).toContain("landslideRisk=landslide"); + }); + + it("getTransactionsSummary forwards ownershipType (count must match filtered rows)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ median_price_m2: 15000, avg_area: 55, total: 100 }), + }); + + const { getTransactionsSummary } = await import("../api-client.js"); + await getTransactionsSummary({ + district: "Kraków", + ownershipType: "2,8", + }); + + const url = mockFetch.mock.calls[0]![0] as string; + // Same drift guard: a summary that drops the filter reports an unfiltered total. + expect(url).toMatch(/ownershipType=2(%2C|,)8/); + }); + it("fetchApiPost sends POST with JSON body", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -319,11 +660,42 @@ describe("api-client", () => { await searchParcels("146518", 5); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/parcels/search"); + expect(url).toContain("/api/v1/parcels/search"); expect(url).toContain("q=146518"); expect(url).toContain("limit=5"); }); + it("resolveParcel builds correct URL and omits empty params", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ query: { mode: "q", q: "Wawer 27" }, coverage: "covered", as_of: null, matches: [], truncated: false }), + }); + + const { resolveParcel } = await import("../api-client.js"); + await resolveParcel({ q: "Wawer 27" }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("/api/v1/parcels/resolve"); + expect(url).toContain("q=Wawer+27"); + expect(url).not.toContain("parcelId="); + expect(url).not.toContain("lat="); + }); + + it("resolveParcel serializes numeric lat/lng", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ query: { mode: "latlng", lat: 52.12, lng: 21.05 }, coverage: "covered", as_of: null, matches: [], truncated: false }), + }); + + const { resolveParcel } = await import("../api-client.js"); + await resolveParcel({ lat: 52.12, lng: 21.05 }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain("lat=52.12"); + expect(url).toContain("lng=21.05"); + expect(url).not.toContain("q="); + }); + it("searchByPolygon sends POST to spatial endpoint", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -338,7 +710,7 @@ describe("api-client", () => { }); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/transactions/spatial"); + expect(url).toContain("/api/v1/transactions/spatial"); const opts = mockFetch.mock.calls[0]![1] as RequestInit; expect(opts.method).toBe("POST"); const body = JSON.parse(opts.body as string) as Record; @@ -346,6 +718,23 @@ describe("api-client", () => { expect(body.minPrice).toBe(300000); }); + it("searchByPolygon forwards ownershipType in the POST body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ type: "FeatureCollection", features: [], total: 0, truncated: false }), + }); + + const { searchByPolygon } = await import("../api-client.js"); + await searchByPolygon({ + polygon: { type: "Polygon", coordinates: [[[21, 52], [21.01, 52], [21.01, 52.01], [21, 52.01], [21, 52]]] }, + ownershipType: "2,8", + }); + + const opts = mockFetch.mock.calls[0]![1] as RequestInit; + const body = JSON.parse(opts.body as string) as Record; + expect(body.ownershipType).toBe("2,8"); + }); + it("402 + OAuth ctx: 'na koncie' wording (no 'kluczem')", async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -369,12 +758,12 @@ describe("api-client", () => { expect((err as Error).message).toContain("disconnect and reconnect"); }); - it("401 + API key: prompts api/keys check", async () => { + it("401 + API key: prompts ustawienia check", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }); const { getStats } = await import("../api-client.js"); const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); expect((err as Error).message).toContain("API key rejected"); - expect((err as Error).message).toContain("https://cenogram.pl/api/keys"); + expect((err as Error).message).toContain("https://cenogram.pl/ustawienia#api-keys"); }); it("403 email_not_verified: prompts inbox check", async () => { @@ -388,15 +777,98 @@ describe("api-client", () => { expect((err as Error).message).toContain("not verified"); }); + // No longer asserts "maintenance": 503 also covers a disabled feature, a read-only failover + // and an unavailable dataset, so the message relays the API's reason rather than guessing one. it("503: temporary unavailability with retry hint", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 503, json: () => Promise.resolve({}) }); const { getStats } = await import("../api-client.js"); const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); expect((err as Error).message).toContain("unavailable"); - expect((err as Error).message).toContain("maintenance"); expect((err as Error).message).toContain("Try again"); }); + it("400 surfaces specific body.error from custom reply.send", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: "Maximum 5 districts allowed" }), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toBe("Invalid request: Maximum 5 districts allowed"); + }); + + it("400 surfaces body.message from Fastify AJV (error=FastifyError, prod shape)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ + statusCode: 400, + error: "FastifyError", + message: "body/districts must NOT have fewer than 1 characters", + }), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toContain("Invalid request:"); + expect((err as Error).message).toContain("body/districts"); + expect((err as Error).message).not.toContain("FastifyError"); + }); + + it("400 surfaces body.message from thrown plain obj (no error field, polygon shape)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ + statusCode: 400, + message: "polygon ring must be closed (first coordinate must equal last)", + }), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toContain("Invalid request:"); + expect((err as Error).message).toContain("ring must be closed"); + }); + + it("400 with empty body falls back to generic message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({}), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toBe("Invalid request (HTTP 400). Check parameters."); + }); + + it("422 surfaces body.message from Fastify (generic body.error)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => Promise.resolve({ + statusCode: 422, + error: "Unprocessable Entity", + message: "polygon must be a closed ring", + }), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toContain("Invalid request:"); + expect((err as Error).message).toContain("closed ring"); + expect((err as Error).message).not.toContain("Unprocessable Entity"); + }); + + it("400 preserves Polish UTF-8 in surfaced message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: "Nieznana dzielnica: Mokotów" }), + }); + const { getStats } = await import("../api-client.js"); + const err = await getStats(`cngrm_${"a".repeat(32)}`).catch((e: Error) => e); + expect((err as Error).message).toBe("Invalid request: Nieznana dzielnica: Mokotów"); + }); + it("compareLocations builds correct URL with districts and filters", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -407,9 +879,22 @@ describe("api-client", () => { await compareLocations({ districts: "Mokotów,Wola", propertyType: 4, dateFrom: "2024-01-01" }); const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain("/api/transactions/summary/compare"); + expect(url).toContain("/api/v1/transactions/summary/compare"); expect(url).toContain("districts=Mokot"); expect(url).toContain("propertyType=4"); expect(url).toContain("dateFrom=2024-01-01"); }); + + it("compareLocations forwards ownershipType", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ "Mokotów": { median_price_m2: 15000, total: 100 } }), + }); + + const { compareLocations } = await import("../api-client.js"); + await compareLocations({ districts: "Mokotów,Wola", ownershipType: "2,8" }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toMatch(/ownershipType=2(%2C|,)8/); + }); }); diff --git a/src/__tests__/error-messages.test.ts b/src/__tests__/error-messages.test.ts index c726063..7bd251d 100644 --- a/src/__tests__/error-messages.test.ts +++ b/src/__tests__/error-messages.test.ts @@ -28,18 +28,36 @@ describe("authErrorMessage", () => { expect(msg).toContain("disconnect and reconnect"); expect(msg).not.toMatch(/free API key/i); }); - it("API key: prompts user to check api/keys", () => { + it("API key: prompts user to check ustawienia", () => { const msg = authErrorMessage(401, "api_key"); expect(msg).toContain("API key rejected"); - expect(msg).toContain("https://cenogram.pl/api/keys"); + expect(msg).toContain("https://cenogram.pl/ustawienia#api-keys"); }); it("stdio_env (no oauth): API key wording", () => { const msg = authErrorMessage(401, "stdio_env"); expect(msg).toContain("API key rejected"); }); - it("none: API key wording (default fallback)", () => { + // No key configured is the ordinary state of someone who has not signed up, not a broken + // account - so it must lead to the public page, never to /ustawienia (which needs a login). + it("none: sends the caller to the public signup page, not to settings", () => { const msg = authErrorMessage(401, "none"); - expect(msg).toContain("API key rejected"); + expect(msg).toContain("No Cenogram API key configured"); + expect(msg).toContain("https://cenogram.pl/api?src=mcpstdio"); + expect(msg).toContain("CENOGRAM_API_KEY"); + expect(msg).not.toContain("/ustawienia"); + }); + // This is the highest-volume signup link this package emits, and it is the only reason + // anyone can tell an arrival through it from an arrival through the REST API. The server + // sends its own signup URL tagged for REST callers; taking that one would erase the split. + it("none: keeps its own source tag even when the API sends a signup URL", () => { + const msg = authErrorMessage(401, "none", { + signup_url: "https://cenogram.pl/api?src=api-401", + } as Parameters[2]); + expect(msg).toContain("src=mcpstdio"); + expect(msg).not.toContain("src=api-401"); + }); + it("none: puts the query before the fragment", () => { + expect(authErrorMessage(401, "none")).not.toMatch(/#.*\?src=/); }); }); @@ -49,7 +67,7 @@ describe("authErrorMessage", () => { expect(msg).toContain("Insufficient credits"); expect(msg).toContain("balance: 5"); expect(msg).toContain("query cost: 10"); - expect(msg).toContain("https://cenogram.pl/api#cennik"); + expect(msg).toContain("https://cenogram.pl/api?src=mcpstdio#cennik"); }); it("API key: 'key's account' wording, parses balance/required", () => { const msg = authErrorMessage(402, "api_key", { currentBalance: 0, creditsRequired: 2 }); @@ -62,6 +80,37 @@ describe("authErrorMessage", () => { expect(msg).toContain("balance: 0"); expect(msg).toContain("query cost: ?"); }); + + // An expired trial freezes the balance instead of spending it, so the credits template + // describes the wrong problem: it would report "balance: 300, query cost: 1" and leave the + // caller looking for a maths error. The API states the real reason; relay it untouched. + describe("trial_expired", () => { + const trialBody = { + error: "trial_expired", + message: + "Your 14-day free API trial has expired, so this request was not charged. Your remaining 300 token(s) are frozen, not lost - they become spendable again once you subscribe to Starter at https://cenogram.pl/api#cennik", + currentBalance: 300, + creditsRequired: 1, + upgrade: "https://cenogram.pl/api#cennik", + }; + + it("relays the API's explanation instead of the credits template", () => { + const msg = authErrorMessage(402, "api_key", trialBody); + expect(msg).toBe(trialBody.message); + expect(msg).not.toContain("Insufficient credits"); + expect(msg).not.toContain("query cost"); + }); + + it("appends the upgrade URL when the message does not already carry it", () => { + const msg = authErrorMessage(402, "oauth", { ...trialBody, message: "Trial over." }); + expect(msg).toBe("Trial over. (https://cenogram.pl/api#cennik)"); + }); + + it("falls back to the credits template when the API sent no explanation", () => { + const msg = authErrorMessage(402, "api_key", { error: "trial_expired", currentBalance: 7, creditsRequired: 2 }); + expect(msg).toContain("balance: 7"); + }); + }); }); describe("403", () => { @@ -70,8 +119,17 @@ describe("authErrorMessage", () => { expect(msg).toContain("not verified"); expect(msg).toContain("Check your inbox"); }); - it("generic 403: HTTP 403 mention", () => { - const msg = authErrorMessage(403, "oauth", { error: "something_else" }); + // A 403 can be a rate-limit ban, the demo cap or a plan restriction, and each ships its own + // explanation. The bare "Access denied (HTTP 403)" swallowed all three. + it("relays the API's reason", () => { + const msg = authErrorMessage(403, "oauth", { + error: "Rate limit ban: too many failed attempts. Try again in 15 minutes.", + }); + expect(msg).toContain("Rate limit ban"); + expect(msg).toContain("15 minutes"); + }); + it("generic 403 when the body says nothing", () => { + const msg = authErrorMessage(403, "oauth", {}); expect(msg).toContain("HTTP 403"); }); }); @@ -80,9 +138,74 @@ describe("authErrorMessage", () => { it("temporary unavailability wording with retry hint", () => { const msg = authErrorMessage(503, "api_key"); expect(msg).toContain("unavailable"); - expect(msg).toContain("maintenance"); expect(msg).toContain("Try again"); }); + // 503 is not only maintenance: a disabled feature, a read-only failover and an unavailable + // dataset all land here, each with its own body. Guessing "maintenance mode" hid that. + it("relays the reason and keeps the retry hint", () => { + const msg = authErrorMessage(503, "api_key", { error: "data_unavailable", message: "Dane transakcyjne chwilowo niedostępne" }); + expect(msg).toBe("Cenogram temporarily unavailable: Dane transakcyjne chwilowo niedostępne. Try again shortly."); + }); + it("does not double the full stop when the API's text has one", () => { + const msg = authErrorMessage(503, "api_key", { message: "Wycena jest tymczasowo wyłączona." }); + expect(msg).not.toContain(".."); + }); + // Half of the API's 503 bodies end with their own "try again", in Polish or English. + it.each([ + ["Service in read-only mode. Try again in a few minutes."], + ["Wycena (AVM) jest tymczasowo wyłączona. Spróbuj ponownie za kilka minut."], + ])("does not stack a second retry hint on %s", (text) => { + const msg = authErrorMessage(503, "api_key", { error: text }); + expect(msg).toContain(text); + expect(msg).not.toContain("Try again shortly"); + }); + }); + + // 410 used to fall into the default branch, which ends with "Try again shortly" - the one piece + // of advice that can never work for a permanently retired endpoint, and an invitation to loop. + describe("410", () => { + const goneBody = { + error: "Gone", + message: "This endpoint has moved to /api/v1. The unversioned /api data surface has been retired.", + successor: "/api/v1/transactions?limit=10", + }; + + it("says it is permanent and names the successor", () => { + const msg = authErrorMessage(410, "api_key", goneBody); + expect(msg).toContain("This endpoint has moved to /api/v1."); + expect(msg).toContain("/api/v1/transactions?limit=10"); + expect(msg).toContain("permanent"); + expect(msg).toContain("@cenogram/mcp-server"); + expect(msg).not.toMatch(/try again/i); + }); + + it("works without a successor", () => { + const msg = authErrorMessage(410, "api_key", {}); + expect(msg).toContain("retired"); + expect(msg).toContain("permanent"); + expect(msg).not.toMatch(/try again/i); + }); + }); + + describe("404", () => { + // The API frames unknown location/county as a client-correctable 404 and now returns a + // self-correcting body.error (e.g. "Unknown location: X. ... List covered locations first ..."). + // The MCP layer must surface that specific message verbatim so the model can self-correct, + // not swallow it behind a generic string. + it("passes the API's specific error message through verbatim", () => { + const specific = + 'Unknown location: Sandomierz. Not a covered county for this tool. List covered locations first (the /locations coverage catalog) or pass a 4-digit county TERYT code; districts are supported only for Warszawa (6-digit TERYT).'; + expect(authErrorMessage(404, "oauth", { error: specific })).toBe(specific); + }); + it("prefers body.message over body.error when both present", () => { + const msg = authErrorMessage(404, "api_key", { error: "raw", message: "human readable" }); + expect(msg).toBe("human readable"); + }); + it("falls back to a generic hint when body carries no message", () => { + const msg = authErrorMessage(404, "api_key", {}); + expect(msg).toContain("Not found"); + expect(msg).toContain("location name or TERYT"); + }); }); describe("other (500/502/504)", () => { diff --git a/src/__tests__/formatters.test.ts b/src/__tests__/formatters.test.ts index abcda76..56b1c40 100644 --- a/src/__tests__/formatters.test.ts +++ b/src/__tests__/formatters.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { findGuardToken } from "./guard-tokens.js"; import { formatPLN, formatArea, @@ -9,11 +10,28 @@ import { formatPriceStats, formatHistogram, formatParcelResults, + formatParcelResolve, formatSpatialResults, formatCompareResults, formatLocationHierarchy, + formatRentalYield, + formatRentalYieldLocations, + formatPriceSpread, + formatPriceSpreadLocations, + formatValuation, + formatBuildingBreakdown, + formatFloodBreakdown, + formatHeritageBreakdown, + formatLandslideBreakdown, + formatSurroundings, + formatTransitBreakdown, + formatPermitsBreakdown, + formatPlanningBreakdown, + formatFarmland, + formatDemographics, + formatParcelReport, } from "../formatters.js"; -import type { Transaction, TransactionsResponse, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, SpatialSearchResponse, SpatialFeature, CompareResponse, LocationItem } from "../api-client.js"; +import type { Transaction, TransactionsResponse, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, SpatialSearchResponse, SpatialFeature, CompareResponse, LocationItem, RentalYieldResponse, RentalYieldLocationsResponse, PriceSpreadResponse, PriceSpreadLocationsResponse, ValuationResponse, BuildingBreakdownResponse, FloodBreakdownResponse, HeritageBreakdownResponse, LandslideBreakdownResponse, SurroundingsResponse, TransitBreakdownResponse, PermitsResponse, PlanningResponse, FarmlandResponse, DemographicsResponse, ParcelResolveResponse, ParcelReportResponse } from "../api-client.js"; const sampleTx: Transaction = { id: "1", @@ -189,6 +207,1020 @@ describe("formatTransaction", () => { }); }); +describe("formatTransaction — building attrs", () => { + // Developed land, single building with full attrs (NUMERIC arrives as string over the wire). + const developedTx: Transaction = { + ...sampleTx, + property_type: 3, + usable_area_m2: null, + parcel_area: 800, + building_count: 1, + footprint_area_m2: "120.50" as unknown as number, + building_storeys: 2, + est_total_area_m2: "241.00" as unknown as number, + }; + + it("renders footprint, storeys and estimated total floor area", () => { + const result = formatTransaction(developedTx); + expect(result).toContain("Building footprint:"); + expect(result).toContain("120,5"); + expect(result).toContain("Storeys: 2"); + expect(result).toContain("Est. total floor area:"); + expect(result).toContain("241"); + expect(result).toContain("estimate: footprint × storeys, not from deed"); + }); + + it("never names the source register", () => { + const result = formatTransaction(developedTx).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); + + it("gates on building_count: no building line when count is null", () => { + const noBld: Transaction = { ...developedTx, building_count: null }; + const result = formatTransaction(noBld); + expect(result).not.toContain("Building footprint:"); + expect(result).not.toContain("Storeys:"); + }); + + it("multi-building: omits storeys (null) but keeps footprint sum + estimate", () => { + const multi: Transaction = { + ...developedTx, + building_count: 3, + building_storeys: null, + footprint_area_m2: "450.00" as unknown as number, + est_total_area_m2: "900.00" as unknown as number, + }; + const result = formatTransaction(multi); + expect(result).toContain("Building footprint:"); + expect(result).not.toContain("Storeys:"); + expect(result).toContain("Est. total floor area:"); + }); + + it("surfaces the transaction id when the row has buildings (building_count != null)", () => { + const result = formatTransaction({ ...developedTx, id: "abc-123" }); + expect(result).toContain("id: abc-123"); + }); + + // id is now UNCONDITIONAL (feeds get_building_breakdown AND the map deep link), + // no longer gated on building_count. The base sampleTx has no buildings. + it("surfaces the id even when there are no buildings (deep-link broaden)", () => { + const result = formatTransaction({ ...sampleTx, building_count: null, id: "abc-123" }); + expect(result).toContain("id: abc-123"); + }); + + it("surfaces the id even when footprint/storeys/est are all null (buildings exist, no attrs)", () => { + const attrlessButCounted: Transaction = { + ...sampleTx, + id: "abc-123", + building_count: 2, + footprint_area_m2: null, + building_storeys: null, + est_total_area_m2: null, + }; + const result = formatTransaction(attrlessButCounted); + expect(result).toContain("id: abc-123"); + }); +}); + +describe("formatBuildingBreakdown", () => { + const full: BuildingBreakdownResponse = { + data: [ + { building_type: 110, footprint_area_m2: "250.00" as unknown as number, footprint_area_alt_m2: null, footprint_divergent: null, storeys: 3, est_total_area_m2: "750.00" as unknown as number, match_confidence: "high" }, + { building_type: 127, footprint_area_m2: "40.00" as unknown as number, footprint_area_alt_m2: null, footprint_divergent: null, storeys: 1, est_total_area_m2: "40.00" as unknown as number, match_confidence: "low" }, + ], + truncated: false, + }; + + it("renders one numbered line per building with type, footprint, storeys, estimate, confidence", () => { + const result = formatBuildingBreakdown(full); + expect(result).toContain("Per-building breakdown (2 buildings)"); + expect(result).toContain("1. Residential (Mieszkalny)"); + expect(result).toContain("2. Farm/Utility (Gospodarczy)"); + expect(result).toMatch(/footprint 250/); + expect(result).toContain("storeys 3"); + expect(result).toContain("estimate: footprint × storeys, not from deed"); + expect(result).toContain("match confidence: high"); + expect(result).toContain("match confidence: low"); + }); + + it("singular 'building' when there is exactly one", () => { + const one: BuildingBreakdownResponse = { data: [full.data[0]!], truncated: false }; + const result = formatBuildingBreakdown(one); + expect(result).toContain("(1 building)"); + expect(result).not.toContain("(1 buildings)"); + }); + + it("shows the alternate footprint + divergence flag only when divergent", () => { + const divergent: BuildingBreakdownResponse = { + data: [{ building_type: 110, footprint_area_m2: "250.00" as unknown as number, footprint_area_alt_m2: "280.00" as unknown as number, footprint_divergent: true, storeys: 2, est_total_area_m2: "500.00" as unknown as number, match_confidence: "high" }], + truncated: false, + }; + const result = formatBuildingBreakdown(divergent); + expect(result).toContain("alt. measurement"); + expect(result).toContain("diverge"); + expect(result).toMatch(/280/); + }); + + it("renders nothing extra for null fields (no 'null', no alt, no confidence)", () => { + const sparse: BuildingBreakdownResponse = { + data: [{ building_type: null, footprint_area_m2: null, footprint_area_alt_m2: null, footprint_divergent: null, storeys: null, est_total_area_m2: null, match_confidence: null }], + truncated: false, + }; + const result = formatBuildingBreakdown(sparse); + expect(result).toContain("1. Building"); // fallback label when building_type is null + expect(result).not.toContain("null"); + expect(result).not.toContain("alt. measurement"); + expect(result).not.toContain("match confidence"); + }); + + it("falls back to 'Type N' for an unknown building_type code", () => { + const unknown: BuildingBreakdownResponse = { + data: [{ building_type: 999, footprint_area_m2: "10.00" as unknown as number, footprint_area_alt_m2: null, footprint_divergent: null, storeys: null, est_total_area_m2: null, match_confidence: null }], + truncated: false, + }; + expect(formatBuildingBreakdown(unknown)).toContain("Type 999"); + }); + + it("empty data → friendly message (covers no-buildings and unknown id)", () => { + expect(formatBuildingBreakdown({ data: [], truncated: false })).toContain("No per-building data available"); + }); + + it("truncated → appends the 500-building note", () => { + const result = formatBuildingBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 buildings"); + }); + + it("never names the source register", () => { + const result = formatBuildingBreakdown(full).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); +}); + +describe("formatTransaction — flood-hazard", () => { + it("surfaces a risk line with the return-period note when flood_risk is set", () => { + const result = formatTransaction({ ...sampleTx, flood_risk: "high" }); + expect(result).toContain("Flood risk: high"); + expect(result).toContain("mapped flood-hazard zone"); + expect(result).toContain("1-in-10-year"); + }); + + it("two-state: no flood line at all when flood_risk is null (never asserts safety)", () => { + const result = formatTransaction({ ...sampleTx, flood_risk: null }); + expect(result).not.toContain("Flood risk"); + }); + + it("two-state: no flood line when flood_risk is absent", () => { + const result = formatTransaction(sampleTx); + expect(result).not.toContain("Flood risk"); + }); +}); + +describe("formatFloodBreakdown", () => { + const full: FloodBreakdownResponse = { + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN labels, no source-register fingerprint). + { + flood_risk: "high", severity_rank: 1, worst_scenario: "river flood, 1-in-10-year", source: "river", + depth_class: null, pct_in_zone: "45.00" as unknown as number, nearest_zone_m: 0, + scenarios: [ + { scenario: "river flood, 1-in-10-year", source: "river", returnPeriod: 10, severity: 1, isLeveeFailure: false, depthClass: null }, + { scenario: "river flood, 1-in-100-year", source: "river", returnPeriod: 100, severity: 2, isLeveeFailure: false, depthClass: null }, + ], + }, + { + flood_risk: "medium", severity_rank: 2, worst_scenario: "coastal flood, 1-in-100-year", source: "coastal", + depth_class: null, pct_in_zone: "100.00" as unknown as number, nearest_zone_m: 0, + scenarios: [{ scenario: "coastal flood, 1-in-100-year", source: "coastal", returnPeriod: 100, severity: 2, isLeveeFailure: false, depthClass: null }], + }, + ], + truncated: false, + }; + + it("renders one numbered line per in-zone parcel with risk, source, share and scenarios", () => { + const result = formatFloodBreakdown(full); + expect(result).toContain("Per-parcel flood-zone breakdown (2 parcels in a mapped flood-hazard zone)"); + expect(result).toContain("1. risk: high (~1-in-10-year)"); + expect(result).toContain("source: river"); + expect(result).toContain("45% of the parcel in the worst-scenario zone"); + expect(result).toContain("scenarios: river flood, 1-in-10-year; river flood, 1-in-100-year"); + expect(result).toContain("2. risk: medium"); + expect(result).toContain("source: coastal"); + }); + + it("never leaks the source register's notation or tokens", () => { + expect(findGuardToken(formatFloodBreakdown(full))).toBeNull(); + }); + + it("singular 'parcel' when there is exactly one", () => { + const one: FloodBreakdownResponse = { data: [full.data[0]!], truncated: false }; + const result = formatFloodBreakdown(one); + expect(result).toContain("(1 parcel in"); + expect(result).not.toContain("(1 parcels"); + }); + + it("two-state empty data → neutral message that never asserts safety", () => { + const result = formatFloodBreakdown({ data: [], truncated: false }); + expect(result).toContain("No mapped flood-hazard zone"); + expect(result).toContain("not a guarantee of safety"); + }); + + it("does not render deferred placeholder fields (depth_class null, nearest_zone_m 0)", () => { + const result = formatFloodBreakdown(full); + expect(result).not.toContain("depth"); + expect(result).not.toContain("nearest"); + expect(result).not.toContain("null"); + }); + + it("truncated → appends the 500-parcel note", () => { + const result = formatFloodBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 parcels"); + }); +}); + +describe("formatSurroundings", () => { + const full: SurroundingsResponse = { + data: [ + { + assessed: true, + cemetery_distance_m: 240.5, + landfill_distance_m: null, + sewage_treatment_distance_m: null, + industrial_area_distance_m: 890.0, + industrial_plant_distance_m: 1500.0, + livestock_farm_distance_m: null, + }, + { + assessed: true, + cemetery_distance_m: 0, + // Distances may arrive as strings over the wire (REAL → driver/serializer variance). + landfill_distance_m: "1234.56" as unknown as number, + sewage_treatment_distance_m: null, + industrial_area_distance_m: null, + industrial_plant_distance_m: null, + livestock_farm_distance_m: "2750.4" as unknown as number, + }, + { + assessed: false, + cemetery_distance_m: null, + landfill_distance_m: null, + sewage_treatment_distance_m: null, + industrial_area_distance_m: null, + industrial_plant_distance_m: null, + livestock_farm_distance_m: null, + }, + ], + truncated: false, + }; + + it("renders one numbered line per plot with approximate distances and radius-bounded absences", () => { + const result = formatSurroundings(full); + expect(result).toContain("Per-parcel surroundings (3 plots;"); + expect(result).toContain('"~" = approximate'); + expect(result).toContain("1. cemetery: ~241 m"); + expect(result).toContain("landfill (waste disposal): none within 3 km"); + expect(result).toContain("sewage treatment plant: none within 2 km"); + expect(result).toContain("industrial/storage area: ~890 m"); + expect(result).toContain("large industrial plant: ~1500 m"); + expect(result).toContain("intensive livestock farm: none within 3 km"); + }); + + it("distance 0 → 'on or adjoining the plot', string distances coerced", () => { + const result = formatSurroundings(full); + expect(result).toContain("2. cemetery: on or adjoining the plot"); + expect(result).toContain("landfill (waste disposal): ~1235 m"); + expect(result).toContain("intensive livestock farm: ~2750 m"); // string coercion, new category + expect(result).toContain("large industrial plant: none within 3 km"); + }); + + it("assessed=false → 'not assessed yet' line with no distance claims", () => { + const result = formatSurroundings(full); + expect(result).toContain("3. not assessed yet"); + expect(result).not.toContain("3. cemetery"); + }); + + it("singular 'plot' when there is exactly one", () => { + const one: SurroundingsResponse = { data: [full.data[0]!], truncated: false }; + const result = formatSurroundings(one); + expect(result).toContain("(1 plot;"); + expect(result).not.toContain("(1 plots"); + }); + + it("two-state empty data → neutral message (no linked plots or unknown id)", () => { + const result = formatSurroundings({ data: [], truncated: false }); + expect(result).toContain("No surroundings data is available"); + }); + + it("handles an absent truncated flag (optional over the wire)", () => { + const result = formatSurroundings({ data: [full.data[0]!] }); + expect(result).not.toContain("first 500"); + }); + + it("truncated → appends the 500-plot note", () => { + const result = formatSurroundings({ ...full, truncated: true }); + expect(result).toContain("first 500 plots"); + }); + + it("output vocabulary stays within the documented labels", () => { + // Positive guard: every line of the formatted output must match one of the documented, + // user-facing shapes — the header, numbered rows built from the four category labels with + // an approximate distance / radius-bounded absence / adjacency value, the pending-plot + // note, the truncation footer, and the neutral empty-data message. Any other wording + // (internal names, stray fields) fails the whitelist. + const CATEGORY = "(?:cemetery|landfill \\(waste disposal\\)|sewage treatment plant|industrial\\/storage area|large industrial plant|intensive livestock farm)"; + const VALUE = "(?:~\\d+ m|none within \\d+ km|on or adjoining the plot)"; + const CELL = `${CATEGORY}: ${VALUE}`; + const allowedLines: RegExp[] = [ + /^Per-parcel surroundings \(\d+ plots?; distance from the plot boundary to the nearest mapped object, "~" = approximate\):$/, + /^$/, + new RegExp(`^\\d+\\. ${CELL}(?: \\| ${CELL})*$`), + /^\d+\. not assessed yet — this plot has not been evaluated \(no statement either way\)$/, + /^Showing the first 500 plots \(the transaction is linked to more\)\.$/, + /^No surroundings data is available for this transaction \(no linked plots, or the id was not found\)\.$/, + ]; + + const outputs = [ + formatSurroundings(full), + formatSurroundings({ ...full, truncated: true }), + formatSurroundings({ data: [], truncated: false }), + ]; + for (const output of outputs) { + for (const line of output.split("\n")) { + expect( + allowedLines.some((pattern) => pattern.test(line)), + `line outside the documented vocabulary: "${line}"`, + ).toBe(true); + } + } + }); +}); + +describe("formatTransitBreakdown", () => { + const full: TransitBreakdownResponse = { + data: [ + { + rail_distance_m: 850, rail_stop_name: "Central Station", + metro_distance_m: null, metro_stop_name: null, + tram_distance_m: 300, tram_stop_name: "Market Square", + bus_distance_m: 120, bus_stop_name: "Post Office", + }, + { + rail_distance_m: null, rail_stop_name: null, + metro_distance_m: null, metro_stop_name: null, + tram_distance_m: null, tram_stop_name: null, + bus_distance_m: 450, bus_stop_name: "Town Hall", + }, + ], + truncated: false, + }; + + it("renders one numbered line per parcel with distance + stop name per mode present", () => { + const result = formatTransitBreakdown(full); + expect(result).toContain("Per-parcel public transport access (2 parcels"); + expect(result).toContain("1. Rail: 850 m (Central Station)"); + expect(result).toContain("Tram: 300 m (Market Square)"); + expect(result).toContain("Bus: 120 m (Post Office)"); + expect(result).toContain("2. Bus: 450 m (Town Hall)"); + }); + + it("skips modes with a null distance (two-state: null ≠ no service)", () => { + const result = formatTransitBreakdown(full); + // Row 1 has no metro — "Metro:" must not appear anywhere near row 1's line. + const row1 = result.split("\n").find((l) => l.startsWith("1. ")); + expect(row1).toBeDefined(); + expect(row1).not.toContain("Metro:"); + // Row 2 has only bus — rail/metro/tram absent from its line. + const row2 = result.split("\n").find((l) => l.startsWith("2. ")); + expect(row2).toBeDefined(); + expect(row2).not.toContain("Rail:"); + expect(row2).not.toContain("Metro:"); + expect(row2).not.toContain("Tram:"); + }); + + it("singular 'parcel' when there is exactly one", () => { + const one: TransitBreakdownResponse = { data: [full.data[0]!], truncated: false }; + const result = formatTransitBreakdown(one); + expect(result).toContain("(1 parcel with"); + expect(result).not.toContain("(1 parcels"); + }); + + it("two-state empty data → neutral message that never asserts 'no transit access'", () => { + const result = formatTransitBreakdown({ data: [], truncated: false }); + expect(result).toContain("No public transport stop is recorded"); + expect(result).not.toContain("no transit access"); + }); + + it("always appends the GTFS coverage-gap note (empty and non-empty)", () => { + const nonEmpty = formatTransitBreakdown(full); + const empty = formatTransitBreakdown({ data: [], truncated: false }); + expect(nonEmpty).toContain("never read as 'no public transport access'"); + expect(empty).toContain("never read as 'no public transport access'"); + }); + + it("truncated → appends the 500-parcel note", () => { + const result = formatTransitBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 parcels"); + }); + + it("never names a feed aggregator, carrier, or transit authority", () => { + expect(findGuardToken(formatTransitBreakdown(full))).toBeNull(); + }); +}); + +describe("formatPlanningBreakdown", () => { + const covered: PlanningResponse = { + data: [ + { + parcel_ord: 1, + kind: "zone", + zone_symbol: "SW", + zone_name: "multi-family residential zone", + pct_of_parcel: 80, + max_building_height_m: 12, + max_development_intensity: 1.2, + max_built_up_coverage_pct: 40, + min_bio_active_area_pct: 30, + params_mixed: false, + effective_from: "2025-12-03", + }, + { + parcel_ord: 1, + kind: "zone", + zone_symbol: "SJ", + zone_name: "single-family residential zone", + // Wire values may arrive as NUMERIC strings — the formatter must coerce. + pct_of_parcel: "20" as unknown as number, + max_building_height_m: null, + max_development_intensity: null, + max_built_up_coverage_pct: null, + min_bio_active_area_pct: null, + params_mixed: true, + effective_from: "2025-12-03", + }, + { + parcel_ord: 1, + kind: "infill_area", + zone_symbol: null, + zone_name: null, + pct_of_parcel: 55, + max_building_height_m: null, + max_development_intensity: null, + max_built_up_coverage_pct: null, + min_bio_active_area_pct: null, + params_mixed: false, + effective_from: "2025-12-03", + }, + ], + truncated: false, + coverage: "covered", + parcels_total: 1, + parcels_covered: 1, + note: "server note (formatter authors its own footer)", + }; + + it("covered → header counts zones and overlays, lists symbol + name + share + parameters", () => { + const result = formatPlanningBreakdown(covered); + expect(result).toContain("General plan (plan ogólny) zoning for this transaction's land (2 planning zones, 1 overlay area):"); + expect(result).toContain("1. SW — multi-family residential zone | 80% of the parcel |"); + expect(result).toContain("max building height: 12 m"); + expect(result).toContain("max development intensity: 1.2"); + expect(result).toContain("max built-up coverage: 40%"); + expect(result).toContain("min biologically active area: 30%"); + }); + + it("covered → coerces string share and omits ambiguous (null) parameters", () => { + const result = formatPlanningBreakdown(covered); + expect(result).toContain("2. SJ — single-family residential zone | 20% of the parcel"); + // Row 2 has all-null parameters → no parameter cell rendered for it. + const row2 = result.split("\n").find((l) => l.startsWith("2. "))!; + expect(row2).not.toContain("max building height"); + }); + + it("covered → params_mixed row carries the ambiguity note (never 'no limit')", () => { + const result = formatPlanningBreakdown(covered); + expect(result).toContain("merges sub-zones with differing building parameters"); + expect(result).toContain("not 'no limit'"); + }); + + it("covered → overlay rendered as its own line with lawful term, independent share", () => { + const result = formatPlanningBreakdown(covered); + expect(result).toContain("3. Infill development area (obszar uzupełnienia zabudowy) — overlay, 55% of the parcel"); + }); + + it("covered → footer explains shares are independent and need not sum to 100%", () => { + const result = formatPlanningBreakdown(covered); + expect(result).toContain("relative to the cadastral parcel geometry"); + expect(result).toContain("need not add up to 100%"); + }); + + // A transaction spanning several parcels repeats a symbol once per parcel. Flat, that reads as a + // duplicate row and the model would double-count the zone; grouped under "Parcel N of M" it does not. + it("multi-parcel → rows grouped per parcel, repeated symbol explained as not-a-duplicate", () => { + const twoParcels: PlanningResponse = { + ...covered, + data: [ + { ...covered.data[0]!, parcel_ord: 1 }, + { ...covered.data[0]!, parcel_ord: 2 }, + ], + parcels_total: 2, + parcels_covered: 2, + }; + const result = formatPlanningBreakdown(twoParcels); + expect(result).toContain("Parcel 1 of 2:"); + expect(result).toContain("Parcel 2 of 2:"); + expect(result).toContain("the same symbol may appear under more than one parcel — that is not a duplicate"); + // Numbering runs across the whole listing, so the second parcel's zone is row 2, not row 1 again. + expect(result).toContain("1. SW — multi-family residential zone"); + expect(result).toContain("2. SW — multi-family residential zone"); + }); + + it("single-parcel → no per-parcel headers, no duplicate caveat", () => { + const result = formatPlanningBreakdown(covered); + expect(result).not.toContain("Parcel 1 of"); + expect(result).not.toContain("not a duplicate"); + }); + + it("not_covered → honest message that never claims the municipality has no plan", () => { + const result = formatPlanningBreakdown({ data: [], truncated: false, coverage: "not_covered", parcels_total: 1, parcels_covered: 0, note: null }); + expect(result).toContain("No published general plan (plan ogólny) data is available"); + expect(result).toContain("this is NOT a statement that no plan exists"); + }); + + it("covered_no_data → distinct message: municipality has a plan, no zone data covers these parcels", () => { + const result = formatPlanningBreakdown({ data: [], truncated: false, coverage: "covered_no_data", parcels_total: 1, parcels_covered: 1, note: null }); + expect(result).toContain("has an adopted general plan"); + expect(result).toContain("no planning-zone data covers these parcels"); + }); + + it("singular 'planning zone' when there is exactly one and no overlays", () => { + const one: PlanningResponse = { ...covered, data: [covered.data[0]!] }; + const result = formatPlanningBreakdown(one); + expect(result).toContain("(1 planning zone):"); + expect(result).not.toContain("planning zones"); + // No overlay row is emitted (the footer still explains overlays in general). + expect(result).not.toContain("— overlay"); + expect(result).not.toContain("overlay area"); + }); + + it("truncated → appends the 500-row note", () => { + const result = formatPlanningBreakdown({ ...covered, truncated: true }); + expect(result).toContain("first 500 rows"); + }); + + it("overlay-only rows → header omits the zone count (never '0 planning zones')", () => { + const overlayOnly: PlanningResponse = { ...covered, data: [covered.data[2]!] }; + const result = formatPlanningBreakdown(overlayOnly); + expect(result).toContain("(1 overlay area):"); + expect(result).not.toContain("0 planning zone"); + expect(result).toContain("1. Infill development area (obszar uzupełnienia zabudowy) — overlay, 55% of the parcel"); + }); + + it("never leaks source or provider terms", () => { + const outputs = [ + formatPlanningBreakdown(covered), + formatPlanningBreakdown({ ...covered, truncated: true }), + formatPlanningBreakdown({ data: [], truncated: false, coverage: "not_covered", parcels_total: 1, parcels_covered: 0, note: null }), + formatPlanningBreakdown({ data: [], truncated: false, coverage: "covered_no_data", parcels_total: 1, parcels_covered: 1, note: null }), + ].join("\n").toLowerCase(); + expect(findGuardToken(outputs)).toBeNull(); + expect(outputs).not.toContain("http"); // no service endpoint of any kind reaches the user + }); +}); + +describe("formatPermitsBreakdown", () => { + const full: PermitsResponse = { + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN labels, no source-register + // fingerprint, no parcel identity / registry number). + { + record_kind: "permit", intent_type: "new_building", object_category: "XIII", works_type: "new_construction", + status: null, decision_date: "2023-05-10", intake_date: "2023-01-02", authority: "Prezydent Miasta Krakowa", + address_street: "ul. Kwiatowa", address_number: "12A", address_city: "Kraków", volume_m3: 1234.7, + }, + { + record_kind: "notification", intent_type: "other", object_category: null, works_type: "other_works", + status: "no_objection", decision_date: null, intake_date: "2022-08-15", authority: "Starosta Powiatu Wielickiego", + address_street: null, address_number: null, address_city: "Wieliczka", volume_m3: null, + }, + ], + truncated: false, + }; + + it("renders one numbered line per record with kind, type, category, date, authority, address, volume", () => { + const result = formatPermitsBreakdown(full); + expect(result).toContain("Building permits & notifications on record for this transaction's parcels (2 records)"); + expect(result).toContain("1. permit"); + expect(result).toContain("intent: new_building"); + expect(result).toContain("works: new_construction"); + expect(result).toContain("category: XIII"); + expect(result).toContain("date: 2023-05-10"); // decision_date preferred + expect(result).toContain("address: ul. Kwiatowa 12A, Kraków"); + expect(result).toContain("volume: 1235 m³"); // rounded + expect(result).toContain("2. notification"); + expect(result).toContain("status: no_objection"); + expect(result).toContain("date: 2022-08-15"); // falls back to intake_date (no decision_date) + expect(result).toContain("address: Wieliczka"); // city only, no stray separators + }); + + it("never leaks the source-register brand", () => { + const result = formatPermitsBreakdown(full).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); + + it("never renders parcel identity, registry number or free-text description", () => { + const result = formatPermitsBreakdown(full).toLowerCase(); + // The register's own record number is covered by the token sweep in the test above. + for (const term of ["parcel_id", "registry", "description", "id:"]) { + expect(result).not.toContain(term); + } + }); + + it("singular 'record' when there is exactly one", () => { + const one: PermitsResponse = { data: [full.data[0]!], truncated: false }; + const result = formatPermitsBreakdown(one); + expect(result).toContain("(1 record)"); + expect(result).not.toContain("(1 records)"); + }); + + it("two-state empty data → neutral message that never asserts nothing was planned", () => { + const result = formatPermitsBreakdown({ data: [], truncated: false }); + expect(result).toContain("No positively-resolved building permit"); + expect(result).toContain("never a statement that nothing was ever planned"); + }); + + it("omits null fields without leaving stray labels", () => { + const result = formatPermitsBreakdown(full); + expect(result).not.toContain("null"); + // Second record has no address_street/number and no volume → no "address: undefined", no "volume:" + expect(result).not.toContain("address: , Wieliczka"); + }); + + it("truncated → appends the 500-record note", () => { + const result = formatPermitsBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 records"); + }); +}); + +describe("formatTransactionList — flood cross-link", () => { + function listOf(txs: Transaction[]): TransactionsResponse { + return { data: txs, pagination: { page: 1, limit: 10, total: txs.length, pages: 1 } }; + } + + it("shows the get_transaction_flood tip when ≥1 row has a mapped flood risk", () => { + const result = formatTransactionList(listOf([{ ...sampleTx, flood_risk: "medium" }])); + expect(result).toContain("get_transaction_flood(transaction_id)"); + }); + + it("no flood tip when no row has a flood risk", () => { + const result = formatTransactionList(listOf([sampleTx])); + expect(result).not.toContain("get_transaction_flood"); + }); +}); + +describe("formatTransaction — heritage listing", () => { + it("surfaces a heritage line with the meaning note when heritage_status is set", () => { + const result = formatTransaction({ ...sampleTx, heritage_status: "listed" }); + expect(result).toContain("Heritage listing: listed"); + expect(result).toContain("protected monument on/at the parcel"); + }); + + it("zone status renders the urban-layout/surroundings note", () => { + const result = formatTransaction({ ...sampleTx, heritage_status: "zone" }); + expect(result).toContain("Heritage listing: zone"); + expect(result).toContain("within a protected urban layout or monument surroundings"); + }); + + it("two-state: no heritage line at all when heritage_status is null (never asserts 'not listed')", () => { + const result = formatTransaction({ ...sampleTx, heritage_status: null }); + expect(result).not.toContain("Heritage"); + }); + + it("two-state: no heritage line when heritage_status is absent", () => { + const result = formatTransaction(sampleTx); + expect(result).not.toContain("Heritage"); + }); +}); + +describe("formatHeritageBreakdown", () => { + const full: HeritageBreakdownResponse = { + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN categories, no source-register fingerprint). + { + heritage_status: "listed", severity_rank: 1, pct_in_zone: "12.50" as unknown as number, site_count: 2, + sites: [ + { category: "building", name: "Townhouse", function: "residential", period: "19th c.", entry_date: "1967-05-12T00:00:00.000Z" }, + { category: "urban_layout", name: "Old Town", function: null, period: null, entry_date: "1953-10-01" }, + ], + }, + { + heritage_status: "zone", severity_rank: 2, pct_in_zone: null, site_count: 1, + sites: [{ category: "surroundings", name: null, function: null, period: null, entry_date: null }], + }, + ], + truncated: false, + }; + + it("renders one numbered line per parcel with status, entry count and share", () => { + const result = formatHeritageBreakdown(full); + expect(result).toContain("Per-parcel heritage-listing breakdown (2 parcels with a detected listing)"); + expect(result).toContain("1. status: listed (protected monument on/at the parcel)"); + expect(result).toContain("entries: 2"); + expect(result).toContain("13% of the parcel in the protected area"); + expect(result).toContain("2. status: zone (within a protected urban layout or monument surroundings)"); + }); + + it("renders individual entries as indented sub-lines with date-only entry_date", () => { + const result = formatHeritageBreakdown(full); + expect(result).toContain(" - building | Townhouse | function: residential | period: 19th c. | entered: 1967-05-12"); + expect(result).not.toContain("T00:00:00"); + expect(result).toContain(" - urban_layout | Old Town | entered: 1953-10-01"); + expect(result).toContain(" - surroundings"); + }); + + it("omits the share cell when pct_in_zone is null (point/line-located entries only)", () => { + const result = formatHeritageBreakdown(full); + const zoneLine = result.split("\n").find((l) => l.startsWith("2."))!; + expect(zoneLine).not.toContain("%"); + }); + + it("always appends the indicative-data disclaimer", () => { + const result = formatHeritageBreakdown(full); + expect(result).toContain("Indicative data"); + expect(result).toContain("regional heritage conservator"); + }); + + it("singular 'parcel' when there is exactly one", () => { + const one: HeritageBreakdownResponse = { data: [full.data[0]!], truncated: false }; + const result = formatHeritageBreakdown(one); + expect(result).toContain("(1 parcel with"); + expect(result).not.toContain("(1 parcels"); + }); + + it("two-state empty data → neutral message that never asserts the absence of protection", () => { + const result = formatHeritageBreakdown({ data: [], truncated: false }); + expect(result).toContain("No heritage-listing records found for this transaction's parcels"); + expect(result).toContain("not a statement that the property is free of heritage protection"); + }); + + it("does not render literal null/undefined for missing fields", () => { + const result = formatHeritageBreakdown(full); + expect(result).not.toContain("null"); + expect(result).not.toContain("undefined"); + }); + + it("truncated → appends the 500-parcel note", () => { + const result = formatHeritageBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 parcels"); + }); + + it("never leaks the source register's tokens", () => { + const lower = formatHeritageBreakdown(full).toLowerCase(); + expect(findGuardToken(lower)).toBeNull(); + expect(lower).not.toContain("rejestr"); // the Polish word alone would still point at a register + }); +}); + +describe("formatTransactionList — heritage cross-link", () => { + function listOf(txs: Transaction[]): TransactionsResponse { + return { data: txs, pagination: { page: 1, limit: 10, total: txs.length, pages: 1 } }; + } + + it("shows the get_transaction_heritage tip when ≥1 row has a detected listing", () => { + const result = formatTransactionList(listOf([{ ...sampleTx, heritage_status: "listed" }])); + expect(result).toContain("get_transaction_heritage(transaction_id)"); + }); + + it("no heritage tip when no row has a heritage status", () => { + const result = formatTransactionList(listOf([sampleTx])); + expect(result).not.toContain("get_transaction_heritage"); + }); +}); + +describe("formatTransaction — landslide-hazard", () => { + it("surfaces a risk line with the category note when landslide_risk is set", () => { + const result = formatTransaction({ ...sampleTx, landslide_risk: "landslide" }); + expect(result).toContain("Landslide risk: landslide"); + expect(result).toContain("a mapped landslide area"); + // Interpretation guard rides along inline — overlap with a mapped area, not "the parcel is a landslide". + expect(result).toContain("intersects a mapped hazard area"); + }); + + it("surfaces the 'threatened' category with its meaning", () => { + const result = formatTransaction({ ...sampleTx, landslide_risk: "threatened" }); + expect(result).toContain("Landslide risk: threatened"); + expect(result).toContain("an area threatened by mass movements"); + }); + + it("two-state: no landslide line at all when landslide_risk is null (never asserts safety)", () => { + const result = formatTransaction({ ...sampleTx, landslide_risk: null }); + expect(result).not.toContain("Landslide risk"); + }); + + it("two-state: no landslide line when landslide_risk is absent", () => { + const result = formatTransaction(sampleTx); + expect(result).not.toContain("Landslide risk"); + }); + + it("landslide_assessed alone never renders anything affirmative", () => { + const result = formatTransaction({ ...sampleTx, landslide_risk: null, landslide_assessed: true }); + expect(result).not.toContain("Landslide"); + expect(result).not.toContain("assessed"); + }); +}); + +describe("formatLandslideBreakdown", () => { + const full: LandslideBreakdownResponse = { + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN kinds, no source-register fingerprint). + { + landslide_risk: "landslide", severity_rank: 1, pct_in_zone: "45.00" as unknown as number, + zones: [ + { kind: "landslide", source_version_date: "2021-03-15" }, + { kind: "threatened", source_version_date: "2019-11-02" }, + ], + }, + { + landslide_risk: "threatened", severity_rank: 2, pct_in_zone: "100.00" as unknown as number, + zones: [{ kind: "threatened", source_version_date: null }], + }, + ], + truncated: false, + }; + + it("renders one numbered line per in-zone parcel with risk, share and zones", () => { + const result = formatLandslideBreakdown(full); + expect(result).toContain("Per-parcel landslide-zone breakdown (2 parcels intersecting a mapped landslide-hazard zone)"); + expect(result).toContain("1. risk: landslide (a mapped landslide area)"); + expect(result).toContain("45% of the parcel in mapped zones"); + expect(result).toContain("zones: landslide (record version date: 2021-03-15); threatened (record version date: 2019-11-02)"); + expect(result).toContain("2. risk: threatened (an area threatened by mass movements)"); + }); + + it("labels the zone date as a record version date, never as a survey date", () => { + const result = formatLandslideBreakdown(full); + expect(result).toContain("record version date"); + expect(result.toLowerCase()).not.toContain("survey"); + expect(result.toLowerCase()).not.toContain("observed"); + }); + + it("appends the interpretation note (overlap with a mapped area, not 'the parcel is a landslide')", () => { + const result = formatLandslideBreakdown(full); + expect(result).toContain("1:10,000 scale"); + expect(result).toContain("not that the parcel itself is a landslide"); + }); + + it("singular 'parcel' when there is exactly one", () => { + const one: LandslideBreakdownResponse = { data: [full.data[0]!], truncated: false }; + const result = formatLandslideBreakdown(one); + expect(result).toContain("(1 parcel intersecting"); + expect(result).not.toContain("(1 parcels"); + }); + + it("two-state empty data → neutral message that never asserts safety", () => { + const result = formatLandslideBreakdown({ data: [], truncated: false }); + expect(result).toContain("No mapped landslide-hazard zone intersects this transaction's parcels"); + expect(result).toContain("not a guarantee of safety"); + }); + + it("skips a missing pct_in_zone and zones without a kind, never rendering 'null'", () => { + const sparse: LandslideBreakdownResponse = { + data: [{ landslide_risk: "landslide", severity_rank: 1, pct_in_zone: null, zones: [{ kind: null, source_version_date: "2020-01-01" }] }], + truncated: false, + }; + const result = formatLandslideBreakdown(sparse); + expect(result).not.toContain("%"); + expect(result).not.toContain("zones:"); + expect(result).not.toContain("null"); + }); + + it("truncated → appends the 500-parcel note", () => { + const result = formatLandslideBreakdown({ ...full, truncated: true }); + expect(result).toContain("first 500 parcels"); + }); +}); + +describe("formatTransactionList — landslide cross-link", () => { + function listOf(txs: Transaction[]): TransactionsResponse { + return { data: txs, pagination: { page: 1, limit: 10, total: txs.length, pages: 1 } }; + } + + it("shows the get_transaction_landslide tip when ≥1 row has a mapped landslide risk", () => { + const result = formatTransactionList(listOf([{ ...sampleTx, landslide_risk: "threatened" }])); + expect(result).toContain("get_transaction_landslide(transaction_id)"); + }); + + it("no landslide tip when no row has a landslide risk", () => { + const result = formatTransactionList(listOf([sampleTx])); + expect(result).not.toContain("get_transaction_landslide"); + }); +}); + +describe("formatTransaction — provenance & raw deed fields", () => { + const landBase: Transaction = { + ...sampleTx, + property_type: 1, + usable_area_m2: null, + price_per_m2: null, + parcel_area: 1500, + rooms: null, + floor: null, + }; + + it("(a) parcel_count >= 2 → [sum of N parcels] marker", () => { + expect(formatTransaction({ ...landBase, parcel_count: 3 })).toContain("sum of 3 parcels"); + }); + + it("(b) area_is_ha_converted → hectares marker", () => { + expect(formatTransaction({ ...landBase, area_is_ha_converted: true })).toContain("converted from hectares"); + }); + + it("combines both parcel markers when both apply", () => { + const r = formatTransaction({ ...landBase, parcel_count: 2, area_is_ha_converted: true }); + expect(r).toContain("sum of 2 parcels"); + expect(r).toContain("converted from hectares"); + }); + + it("(c) property_type_inferred → inferred marker on the type line", () => { + expect(formatTransaction({ ...sampleTx, property_type_inferred: true })).toContain("type inferred"); + }); + + it("property_type_reclassed takes precedence over inferred", () => { + const r = formatTransaction({ ...sampleTx, property_type_reclassed: true, property_type_inferred: true }); + expect(r).toContain("registry recorded land"); + expect(r).not.toContain("type inferred"); + }); + + it("(d) a purely-raw record carries NO provenance markers", () => { + const r = formatTransaction(landBase); + expect(r).not.toContain("[sum of"); + expect(r).not.toContain("converted from hectares"); + expect(r).not.toContain("inferred"); + expect(r).not.toContain("registry recorded land"); + }); + + it("(e) unit_price gating: string '0', == price_gross (number & string), on land → hidden; != on a unit → shown", () => { + // sampleTx.price_gross === 890000; NUMERIC arrives as string over the wire → both forms must gate. + expect(formatTransaction({ ...sampleTx, unit_price: "0" as unknown as number })).not.toContain("Deed unit price"); + expect(formatTransaction({ ...sampleTx, unit_price: 890000 })).not.toContain("Deed unit price"); + expect(formatTransaction({ ...sampleTx, unit_price: "890000" as unknown as number })).not.toContain("Deed unit price"); + expect(formatTransaction({ ...landBase, unit_price: 850000 })).not.toContain("Deed unit price"); + const shown = formatTransaction({ ...sampleTx, unit_price: 850000 }); + expect(shown).toContain("Deed unit price (not per-m²)"); + expect(shown).toMatch(/850/); + }); + + it("ha conversion marks the area even with a single parcel (parcel_count = 1)", () => { + const r = formatTransaction({ ...landBase, parcel_count: 1, area_is_ha_converted: true }); + expect(r).toContain("converted from hectares"); + expect(r).not.toContain("sum of"); + }); + + it("(f) ownership/seller/buyer/land_use render dictionary labels", () => { + const r = formatTransaction({ ...sampleTx, ownership_type: 2, seller_type: 1, buyer_type: 3, land_use: "gruntyRolne" }); + expect(r).toContain("Ownership: Perpetual usufruct"); + expect(r).toContain("Seller: State Treasury"); + expect(r).toContain("Buyer: Natural person"); + expect(r).toContain("Land use: Agricultural land"); + }); + + it("(g) unknown enum code → fallback label", () => { + const r = formatTransaction({ ...sampleTx, ownership_type: 99, seller_type: 88 }); + expect(r).toContain("Ownership: Type 99"); + expect(r).toContain("Seller: Party type 88"); + }); + + it("(h) vat: non-null string shown without a unit; null/empty → absent", () => { + const shown = formatTransaction({ ...sampleTx, vat: "23000.00" as unknown as number }); + expect(shown).toContain("VAT (as recorded"); + expect(shown).toMatch(/23.?000/); + expect(formatTransaction({ ...sampleTx, vat: null })).not.toContain("VAT"); + expect(formatTransaction({ ...sampleTx, vat: "" as unknown as number })).not.toContain("VAT"); + // vat = 0 is a meaningful explicit record (possible exemption / 0% rate) → shown, not hidden. + const zero = formatTransaction({ ...sampleTx, vat: 0 }); + expect(zero).toContain("VAT (as recorded"); + expect(zero).toMatch(/:\s*0/); + }); + + it("(i) ownership_share shown only when share_basis = 'fraction'", () => { + expect(formatTransaction({ ...sampleTx, ownership_share: "1/2", share_basis: "fraction" })).toContain("Share: 1/2"); + expect(formatTransaction({ ...sampleTx, ownership_share: "1/1", share_basis: "full" })).not.toContain("Share: 1/1"); + }); + + it("markers also appear on the spatial path (shared formatTransactionCore)", () => { + const feat: SpatialFeature = { + type: "Feature", + geometry: { type: "Point", coordinates: [21.05, 52.22] }, + properties: { + id: "1", price_gross: 500000, transaction_date: "2024-06-15T00:00:00.000Z", + property_type: 1, market_type: 2, usable_area_m2: null, price_per_m2: null, + rooms: null, floor: null, street: null, building_number: "5", city: "X", district: "X", + parcel_area: 2000, parcel_number: "1", parcel_count: 4, + }, + }; + const res: SpatialSearchResponse = { type: "FeatureCollection", features: [feat], truncated: false, total: 1 }; + expect(formatSpatialResults(res)).toContain("sum of 4 parcels"); + }); +}); + describe("formatTransactionList", () => { it("shows 'no transactions' for empty results", () => { const empty: TransactionsResponse = { @@ -210,6 +1242,20 @@ describe("formatTransactionList", () => { expect(result).toContain("Puławska"); expect(result).toContain("Median"); }); + + it("appends the get_building_breakdown tip when a row has buildings, not otherwise", () => { + const withBld: TransactionsResponse = { + data: [{ ...sampleTx, building_count: 2 }], + pagination: { page: 1, limit: 10, total: 1, pages: 1 }, + }; + expect(formatTransactionList(withBld)).toContain("get_building_breakdown"); + + const noBld: TransactionsResponse = { + data: [sampleTx], + pagination: { page: 1, limit: 10, total: 1, pages: 1 }, + }; + expect(formatTransactionList(noBld)).not.toContain("get_building_breakdown"); + }); }); describe("formatMarketOverview", () => { @@ -262,6 +1308,426 @@ describe("formatPriceStats", () => { }); }); +describe("formatRentalYield", () => { + const full: RentalYieldResponse = { + location: { name: "Warszawa", country_code: "PL", location_type: "city", teryt: "1465" }, + metric: "indicative_gross_rental_yield", + currency: "PLN", + segment: { market_type: "secondary", property_type: "apartment", area_bucket: null }, + result: { gross_yield_pct: 5.5, calculation_method: "ratio_of_market_medians", matched_observations: false }, + inputs: { + rent: { median_monthly_asking_per_m2: 75.49, annualized_per_m2: 905.88, sample_n: 7468, snapshot_date: "2025-11-20" }, + transaction: { median_price_per_m2: 16472, sample_n: 13263, window: { from: "2024-11-20", to: "2025-11-23" } }, + }, + distribution: { + asking_rent_monthly_per_m2: { p10: 50, p25: 62, p50: 75.49, p75: 90, p90: 110 }, + transaction_price_per_m2: { p10: 12000, p25: 14000, p50: 16472, p75: 19000, p90: 22000 }, + }, + assumptions: { vacancy_included: false, tax_included: false, maintenance_included: false, transaction_costs_included: false }, + quality: { coverage: "full", confidence: "high", as_of: "2025-11-23", stale: true, notes: ["indykatywny yield brutto, bez vacancy/podatku", "mianownik: rynek wtórny"] }, + }; + + it("renders the yield, calculation, samples and notes", () => { + const result = formatRentalYield(full); + expect(result).toContain("Warszawa"); + expect(result).toContain("5.5%"); + expect(result).toContain("secondary market"); + expect(result).toContain("rent offers"); + expect(result).toContain("transactions"); + expect(result).toContain("2024-11-20 to 2025-11-23"); + // offer-side freshness: the rent snapshot date renders as a point-in-time suffix + expect(result).toContain("(as of 2025-11-20)"); + expect(result).toContain("Coverage: full"); + expect(result).toContain("indykatywny yield brutto"); + // arithmetic-consistency regression: monthly rent shown with decimals so "× 12" adds up + // (75,49 × 12 = 905,88 ≈ 906), not the misleading rounded "75 × 12 = 906" + expect(result).toContain("75,49"); + expect(result).not.toContain("75 zł/m²/mo"); + }); + + it("uses singular nouns when a sample count is exactly 1", () => { + const single: RentalYieldResponse = { + ...full, + inputs: { + rent: { ...full.inputs.rent, sample_n: 1 }, + transaction: { ...full.inputs.transaction, sample_n: 1 }, + }, + }; + const result = formatRentalYield(single); + expect(result).toContain("1 rent offer (as of"); + expect(result).toContain("1 transaction "); + expect(result).not.toContain("1 rent offers"); + expect(result).not.toContain("1 transactions"); + }); + + it("handles a suppressed / null-yield result without crashing", () => { + const suppressed: RentalYieldResponse = { + ...full, + result: { ...full.result, gross_yield_pct: null }, + inputs: { + rent: { median_monthly_asking_per_m2: null, annualized_per_m2: null, sample_n: null, snapshot_date: "2025-11-20" }, + transaction: { median_price_per_m2: null, sample_n: null, window: { from: null, to: null } }, + }, + quality: { ...full.quality, coverage: "suppressed", confidence: "low", notes: [] }, + }; + const result = formatRentalYield(suppressed); + expect(result).toContain("N/A"); + expect(result).toContain("coverage: suppressed"); + expect(result).toContain("no rent data"); + expect(result).toContain("no transaction data"); + // W1 regression: a null tx sample_n must not leak "N/A transactions" mid-sentence + expect(result).not.toContain("N/A transactions"); + // offer-date suffix is gated on sample_n: a present snapshot_date must NOT show next to "no rent data" + expect(result).not.toContain("(as of"); + }); + + it("never leaks the rent-data source brand", () => { + const result = formatRentalYield(full).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); + + it("no_rental_data → appends list_rental_yield_locations tip, hides REST-URL note", () => { + const noData: RentalYieldResponse = { + ...full, + result: { ...full.result, gross_yield_pct: null }, + inputs: { rent: { ...full.inputs.rent, sample_n: null }, transaction: full.inputs.transaction }, + quality: { ...full.quality, coverage: "no_rental_data", notes: ["Brak danych czynszowych dla tej lokalizacji — listę pokrytych miast zwraca GET /api/v1/rental-yield/locations"] }, + }; + const result = formatRentalYield(noData); + expect(result).toContain("list_rental_yield_locations"); + expect(result).not.toContain("/api/v1/rental-yield/locations"); + }); + + it("strips the legacy /api/ note variant too (old published stdio backward-compat)", () => { + const noData: RentalYieldResponse = { + ...full, + result: { ...full.result, gross_yield_pct: null }, + inputs: { rent: { ...full.inputs.rent, sample_n: null }, transaction: full.inputs.transaction }, + quality: { ...full.quality, coverage: "no_rental_data", notes: ["Brak danych czynszowych dla tej lokalizacji — listę pokrytych miast zwraca GET /api/rental-yield/locations"] }, + }; + const result = formatRentalYield(noData); + expect(result).not.toContain("/api/rental-yield/locations"); + }); +}); + +describe("formatRentalYieldLocations", () => { + const catalog: RentalYieldLocationsResponse = { + data: [ + { location: "Warszawa", county_code: "1465", voivodeship: "Mazowieckie", type: "city", rent_sample_n: 7040, confidence: "high" }, + { location: "legionowski", county_code: "1408", voivodeship: "Mazowieckie", type: "county", rent_sample_n: 120, confidence: "high" }, + ], + meta: { total: 2, snapshot_date: "2026-06-02" }, + }; + + it("renders header with count + snapshot and one line per location", () => { + const result = formatRentalYieldLocations(catalog); + expect(result).toContain("2 locations"); + expect(result).toContain("2026-06-02"); + expect(result).toContain("Warszawa (teryt 1465, Mazowieckie, city) — n=7040, high confidence"); + expect(result).toContain("legionowski (teryt 1408, Mazowieckie, county) — n=120, high confidence"); + }); + + it("singular 'location' when total is 1", () => { + const one: RentalYieldLocationsResponse = { data: [catalog.data[0]!], meta: { total: 1, snapshot_date: "2026-06-02" } }; + const result = formatRentalYieldLocations(one); + expect(result).toContain("1 location,"); + expect(result).not.toContain("1 locations"); + }); + + it("empty data → friendly no-match message", () => { + const result = formatRentalYieldLocations({ data: [], meta: { total: 0, snapshot_date: null } }); + expect(result).toContain("No rental-yield-covered locations match"); + }); + + it("never leaks the rent-data source brand", () => { + const result = formatRentalYieldLocations(catalog).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); +}); + +describe("formatPriceSpread", () => { + const full: PriceSpreadResponse = { + location: { name: "Warszawa", country_code: "PL", location_type: "city", teryt: "1465" }, + metric: "asking_to_transaction_price_spread", + currency: "PLN", + segment: { market_type: "all", property_type: "apartment", area_bucket: null }, + result: { spread_pct: 8.41, calculation_method: "relative_difference_of_market_medians", matched_observations: false }, + inputs: { + asking: { median_price_per_m2: 16585, sample_n: 200, snapshot_date: "2026-06-01" }, + transaction: { median_price_per_m2: 15298, sample_n: 500, window: { from: "2025-06-01", to: "2026-06-01" } }, + }, + distribution: { + asking_sale_per_m2: { p10: 12000, p25: 14500, p50: 16585, p75: 19000, p90: 23000 }, + transaction_price_per_m2: { p10: 11000, p25: 13200, p50: 15298, p75: 18000, p90: 21000 }, + }, + // Realistic production notes (incl. the source-availability caveat) so the brand-scrub test + // below is a genuine guard — not a fixture that happens to omit the risky note (CR B1). + quality: { + coverage: "full", + confidence: "high", + as_of: "2026-06-01", + stale: false, + notes: [ + "spread = how far the median asking price exceeds the median transaction price (may be negative)", + "sale offers are a mix of primary and secondary market", + "denominator: whole market (fractional shares and non-market deeds excluded)", + "offer-source coverage changed on 2026-03-23 (may affect offer counts)", + ], + }, + }; + + it("renders a positive spread with both medians and the market segment", () => { + const result = formatPriceSpread(full); + expect(result).toContain("Warszawa"); + expect(result).toContain("+8.41%"); + expect(result).toContain("above transaction"); + expect(result).toContain("all market"); + expect(result).toContain("2025-06-01 to 2026-06-01"); + // offer-side freshness: the asking snapshot date renders as a point-in-time suffix + expect(result).toContain("(as of 2026-06-01)"); + }); + + it("renders a negative spread as 'below transaction'", () => { + const result = formatPriceSpread({ ...full, result: { ...full.result, spread_pct: -6.67 } }); + expect(result).toContain("-6.67%"); + expect(result).toContain("below transaction"); + }); + + it("handles a suppressed / null-spread result without crashing", () => { + const suppressed: PriceSpreadResponse = { + ...full, + result: { ...full.result, spread_pct: null }, + inputs: { + asking: { median_price_per_m2: null, sample_n: null, snapshot_date: "2026-06-01" }, + transaction: { median_price_per_m2: null, sample_n: null, window: { from: null, to: null } }, + }, + quality: { ...full.quality, coverage: "suppressed", confidence: "low", notes: [] }, + }; + const result = formatPriceSpread(suppressed); + expect(result).toContain("N/A"); + expect(result).toContain("coverage: suppressed"); + expect(result).toContain("no asking data"); + expect(result).toContain("no transaction data"); + expect(result).not.toContain("N/A transactions"); + // offer-date suffix is gated on sample_n: a present snapshot_date must NOT show next to "no asking data" + expect(result).not.toContain("(as of"); + }); + + it("uses singular nouns when a sample count is exactly 1", () => { + const single: PriceSpreadResponse = { + ...full, + inputs: { + asking: { ...full.inputs.asking, sample_n: 1 }, + transaction: { ...full.inputs.transaction, sample_n: 1 }, + }, + }; + const result = formatPriceSpread(single); + expect(result).toContain("1 sale offer (as of"); + expect(result).toContain("1 transaction "); + expect(result).not.toContain("1 sale offers"); + expect(result).not.toContain("1 transactions"); + }); + + it("never leaks the data source brand", () => { + const result = formatPriceSpread(full).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); + + it("no_asking_data → appends list_price_spread_locations tip, hides REST-URL note", () => { + const noData: PriceSpreadResponse = { + ...full, + result: { ...full.result, spread_pct: null }, + inputs: { asking: { ...full.inputs.asking, sample_n: null }, transaction: full.inputs.transaction }, + quality: { ...full.quality, coverage: "no_asking_data", notes: ["Brak danych ofertowych sprzedaży dla tej lokalizacji — listę pokrytych miast zwraca GET /api/v1/price-spread/locations"] }, + }; + const result = formatPriceSpread(noData); + expect(result).toContain("list_price_spread_locations"); + expect(result).not.toContain("/api/v1/price-spread/locations"); + }); + + it("strips the legacy /api/ note variant too (old published stdio backward-compat)", () => { + const noData: PriceSpreadResponse = { + ...full, + result: { ...full.result, spread_pct: null }, + inputs: { asking: { ...full.inputs.asking, sample_n: null }, transaction: full.inputs.transaction }, + quality: { ...full.quality, coverage: "no_asking_data", notes: ["Brak danych ofertowych sprzedaży dla tej lokalizacji — listę pokrytych miast zwraca GET /api/price-spread/locations"] }, + }; + const result = formatPriceSpread(noData); + expect(result).not.toContain("/api/price-spread/locations"); + }); +}); + +describe("formatPriceSpreadLocations", () => { + const catalog: PriceSpreadLocationsResponse = { + data: [ + { location: "Warszawa", county_code: "1465", voivodeship: "Mazowieckie", type: "city", asking_sample_n: 5000, confidence: "high" }, + { location: "Kraków", county_code: "1261", voivodeship: "Małopolskie", type: "city", asking_sample_n: 900, confidence: "high" }, + ], + meta: { total: 2, snapshot_date: "2026-06-02" }, + }; + + it("renders header with count + snapshot and one line per location", () => { + const result = formatPriceSpreadLocations(catalog); + expect(result).toContain("2 locations"); + expect(result).toContain("2026-06-02"); + expect(result).toContain("Warszawa (teryt 1465, Mazowieckie, city) — n=5000, high confidence"); + expect(result).toContain("Kraków (teryt 1261, Małopolskie, city) — n=900, high confidence"); + }); + + it("singular 'location' when total is 1", () => { + const one: PriceSpreadLocationsResponse = { data: [catalog.data[0]!], meta: { total: 1, snapshot_date: "2026-06-02" } }; + const result = formatPriceSpreadLocations(one); + expect(result).toContain("1 location,"); + expect(result).not.toContain("1 locations"); + }); + + it("empty data → friendly no-match message", () => { + const result = formatPriceSpreadLocations({ data: [], meta: { total: 0, snapshot_date: null } }); + expect(result).toContain("No price-spread-covered locations match"); + }); + + it("never leaks the data source brand", () => { + const result = formatPriceSpreadLocations(catalog).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); +}); + +describe("formatValuation", () => { + const covered: ValuationResponse = { + location: { country_code: "PL", lat: 52.2297, lng: 21.0122, county_code: "1465" }, + metric: "estimated_apartment_value", + currency: "PLN", + segment: { property_type: "apartment", market_type: "all", area_m2: 55, rooms: null }, + result: { + estimated_value: 1210000, + price_per_m2: 22000, + value_range_likely: { low: 1100000, high: 1320000 }, + value_range_wide: { low: 950000, high: 1450000 }, + confidence: 0.82, + confidence_band: "high", + }, + inputs: { + comps_total: 34, + radius_m: 1000, + window_months: 24, + comparables: [ + { distance_m: 210, transaction_date: "2025-08-12", area_m2: 54, price_per_m2: 21500, rooms: 3, floor: 4, market_type: "secondary", district: "Mokotów", has_unit_number: true, unit_number: "11_LOK" }, + { distance_m: 640, transaction_date: "2025-05-02", area_m2: 58, price_per_m2: 20800, rooms: 3, floor: 2, market_type: "secondary", district: "Mokotów", has_unit_number: false }, + ], + }, + quality: { + coverage: "covered", + as_of: "2025-11-23", + price_basis: "apartment", + ess: 18.4, + accuracy_segment: "wwa", + note: "Indicative market-value estimate for an apartment. This is an orientation estimate, NOT a certified appraisal (operat szacunkowy).", + }, + }; + + it("renders the estimate, ranges, confidence, comps and disclaimer", () => { + const result = formatValuation(covered); + expect(result).toContain("Apartment value estimate"); + expect(result).toContain("Estimated value:"); + expect(result).toContain("Likely range:"); + expect(result).toContain("Wide range:"); + expect(result).toContain("Confidence: high (0.82)"); + expect(result).toContain("34 comparable transactions within"); + expect(result).toContain("last 24 months"); + expect(result).toContain("Transaction data as of: 2025-11-23"); + expect(result).toContain("Comparables (nearest 2):"); + expect(result).toContain("Mokotów"); + expect(result).toContain("operat szacunkowy"); + }); + + it("no_data → no estimate + refund hint, no range lines", () => { + const noData: ValuationResponse = { + ...covered, + location: { country_code: "PL", lat: 52.9, lng: 19.1, county_code: null }, + result: { + estimated_value: null, price_per_m2: null, + value_range_likely: { low: null, high: null }, value_range_wide: { low: null, high: null }, + confidence: null, confidence_band: null, + }, + inputs: { comps_total: 2, radius_m: null, window_months: 24, comparables: null }, + quality: { ...covered.quality, coverage: "no_data", as_of: null, accuracy_segment: null, ess: null }, + }; + const result = formatValuation(noData); + expect(result).toContain("No estimate"); + expect(result).toContain("refunded"); + expect(result).not.toContain("Likely range:"); + expect(result).toContain("operat szacunkowy"); + }); + + it("not_covered → property-type message", () => { + const notCovered: ValuationResponse = { + ...covered, + result: { estimated_value: null, price_per_m2: null, value_range_likely: { low: null, high: null }, value_range_wide: { low: null, high: null }, confidence: null, confidence_band: null }, + inputs: { comps_total: 0, radius_m: null, window_months: 24, comparables: null }, + quality: { ...covered.quality, coverage: "not_covered", as_of: null }, + }; + const result = formatValuation(notCovered); + expect(result).toContain("apartments only"); + }); + + it("omits the comparables block when includeComps was off (null)", () => { + const noComps: ValuationResponse = { ...covered, inputs: { ...covered.inputs, comparables: null } }; + const result = formatValuation(noComps); + expect(result).not.toContain("Comparables (nearest"); + expect(result).toContain("Estimated value:"); + }); + + // A truncated/proxied body used to throw a raw TypeError naming our internal fields — inside + // a PUBLIC artifact. The server never emits these shapes; the point is that the failure stays boring. + it("degrades to a plain message on a malformed body instead of throwing", () => { + const malformed: unknown[] = [ + undefined, null, {}, [], "boom", + { ...covered, result: undefined }, + { ...covered, quality: undefined }, + { ...covered, inputs: undefined }, + { ...covered, location: undefined }, + { ...covered, segment: undefined }, + ]; + for (const body of malformed) { + const out = formatValuation(body as ValuationResponse); + expect(out).toContain("could not be rendered"); + expect(out).not.toMatch(/TypeError|Cannot read properties/); + } + }); + + it("survives partial nulls inside a well-shaped body", () => { + const ragged = { + ...covered, + result: { ...covered.result, value_range_likely: undefined, value_range_wide: undefined }, + inputs: { ...covered.inputs, comparables: "boom" }, + quality: { ...covered.quality, note: null }, + } as unknown as ValuationResponse; + const out = formatValuation(ragged); + expect(out).toContain("Estimated value:"); + expect(out).not.toContain("Likely range:"); + expect(out).not.toContain("Comparables (nearest"); + expect(out).not.toContain("null"); + }); + + it("says the parcel did not resolve, rather than blaming the neighbourhood", () => { + const unresolved: ValuationResponse = { + ...covered, + location: { country_code: "PL", lat: null, lng: null, county_code: null }, + result: { estimated_value: null, price_per_m2: null, value_range_likely: { low: null, high: null }, value_range_wide: { low: null, high: null }, confidence: null, confidence_band: null }, + inputs: { comps_total: 0, radius_m: null, window_months: 24, comparables: null }, + quality: { ...covered.quality, coverage: "no_data", as_of: null, accuracy_segment: null, ess: null }, + }; + const out = formatValuation(unresolved); + expect(out).toContain("could not be resolved"); + expect(out).toContain("refunded"); + }); + + it("never leaks a source brand", () => { + const result = formatValuation(covered).toLowerCase(); + expect(findGuardToken(result)).toBeNull(); + }); +}); + describe("formatHistogram", () => { const bins: HistogramBin[] = [ { bucket: 0, count: 100, range_min: 0, range_max: 150000 }, @@ -311,6 +1777,47 @@ describe("formatParcelResults", () => { }); +describe("formatParcelResolve", () => { + const base: ParcelResolveResponse = { + query: { mode: "q", q: "Wawer 27" }, + coverage: "covered", + as_of: "2026-06-30T00:00:00.000Z", + matches: [ + { id: "u1", parcel_id: "146518_8.0108.27", parcel_key: "146518_8.0108.27", district: "Wawer", county_code: "1465", parcel_number: "27", area_m2: 1200, has_geometry: true, centroid: { lat: 52.1234, lng: 21.0567 } }, + ], + truncated: false, + }; + + it("formats a match with id, district, area, location and as_of", () => { + const result = formatParcelResolve(base); + expect(result).toContain("Found 1 parcel"); + expect(result).toContain("146518_8.0108.27"); + expect(result).toContain("Wawer"); + expect(result).toContain("52.1234"); + expect(result).toContain("2026-06-30"); + }); + + it("reports not_covered as a refunded miss", () => { + const result = formatParcelResolve({ ...base, coverage: "not_covered", matches: [], as_of: null }); + expect(result).toContain("No parcel matched"); + expect(result).toContain("refunded"); + }); + + it("notes truncation when matches were capped", () => { + const result = formatParcelResolve({ ...base, truncated: true }); + expect(result).toContain("More matches exist"); + }); + + it("renders a paid-plan hint when identity is stripped and no-geometry", () => { + const result = formatParcelResolve({ + ...base, + matches: [{ ...base.matches[0]!, parcel_id: null, parcel_key: null, has_geometry: false, centroid: null }], + }); + expect(result).toContain("requires a paid plan"); + expect(result).toContain("no geometry"); + }); +}); + describe("formatSpatialResults", () => { const sampleFeature: SpatialFeature = { type: "Feature", @@ -384,6 +1891,26 @@ describe("formatSpatialResults", () => { expect(result).toContain("showing 50"); expect(result).toContain("10 more"); }); + + it("surfaces the id inline + breakdown tip on the spatial path when a feature has buildings", () => { + const withBld: SpatialFeature = { + ...sampleFeature, + properties: { ...sampleFeature.properties, id: "abc-123", building_count: 2, footprint_area_m2: "300.00" as unknown as number }, + }; + const res: SpatialSearchResponse = { type: "FeatureCollection", features: [withBld], truncated: false, total: 1 }; + const result = formatSpatialResults(res); + expect(result).toContain("id: abc-123"); + expect(result).toContain("get_building_breakdown"); + }); + + it("surfaces id but NOT the breakdown tip on the spatial path when no feature has buildings", () => { + // id is unconditional (deep link), but the list-level get_building_breakdown tip + // stays gated on at least one feature having buildings. + const res: SpatialSearchResponse = { type: "FeatureCollection", features: [sampleFeature], truncated: false, total: 1 }; + const result = formatSpatialResults(res); + expect(result).not.toContain("get_building_breakdown"); + expect(result).toContain("id: 1"); + }); }); describe("formatCompareResults", () => { @@ -421,6 +1948,77 @@ describe("formatCompareResults", () => { }); }); +describe("formatFarmland", () => { + const full: FarmlandResponse = { + data: [ + { eligible_area_m2: 3984, pct_of_parcel: 87, feature_count: 2 }, + { eligible_area_m2: 12000, pct_of_parcel: null, feature_count: 1 }, + ], + truncated: false, + parcels_total: 3, + parcels_with_data: 2, + as_of: "2026-07-01", + }; + + it("renders one numbered line per matched parcel with area, share and feature count", () => { + const result = formatFarmland(full); + expect(result).toContain("Per-parcel agricultural land-eligibility (2 of 3 linked parcels with a matched eligible area)"); + expect(result).toContain(`1. eligible agricultural area: ${formatArea(3984)} | 87% of the parcel | 2 features`); + }); + + it("omits the share cell when pct_of_parcel is null (parcel measured area unavailable)", () => { + const result = formatFarmland(full); + const secondLine = result.split("\n").find((l) => l.startsWith("2."))!; + expect(secondLine).toContain(`eligible agricultural area: ${formatArea(12000)}`); + expect(secondLine).not.toContain("% of the parcel"); + }); + + it("omits the feature-count cell when a single feature composes the area", () => { + const result = formatFarmland(full); + const secondLine = result.split("\n").find((l) => l.startsWith("2."))!; + expect(secondLine).not.toContain("feature"); + }); + + it("surfaces the coverage counters and the snapshot freshness date", () => { + const result = formatFarmland(full); + expect(result).toContain("2 of 3 linked parcels"); + expect(result).toContain("snapshot as of 2026-07-01"); + expect(result).toContain("updated weekly"); + }); + + it("singular 'parcel' when the transaction links exactly one", () => { + const one: FarmlandResponse = { + data: [full.data[0]!], truncated: false, parcels_total: 1, parcels_with_data: 1, as_of: "2026-07-01", + }; + const result = formatFarmland(one); + expect(result).toContain("(1 of 1 linked parcel with"); + expect(result).not.toContain("linked parcels with"); + }); + + it("two-state empty data → neutral message that never asserts the land is non-agricultural", () => { + const result = formatFarmland({ data: [], truncated: false, parcels_total: 2, parcels_with_data: 0, as_of: null }); + expect(result).toContain("No eligible agricultural area found for the linked parcels"); + expect(result).toContain("not a statement that the property is non-agricultural"); + expect(result.toLowerCase()).toContain("absence of a match is never asserted"); + }); + + it("does not render literal null/undefined for missing fields", () => { + const result = formatFarmland(full); + expect(result).not.toContain("null"); + expect(result).not.toContain("undefined"); + }); + + it("truncated → appends the 500-parcel note", () => { + const result = formatFarmland({ ...full, truncated: true }); + expect(result).toContain("first 500 parcels"); + }); + + it("never leaks the source register's tokens", () => { + const lower = formatFarmland(full).toLowerCase(); + expect(findGuardToken(lower)).toBeNull(); + }); +}); + describe("formatLocationHierarchy", () => { const voivodeships: LocationItem[] = [ { code: "02", name: "dolnośląskie", typeName: null, level: "voivodeship" }, @@ -511,3 +2109,211 @@ describe("formatLocationHierarchy", () => { expect(result).toContain("140101 - Warszawa"); }); }); + +describe("formatDemographics", () => { + const base: DemographicsResponse = { + location: { name: "Warszawa", country_code: "PL", location_type: "city", teryt: "1465", level: "powiat", hierarchy: {} }, + coverage: "full", + indicators: { + population_density: { name: "Gęstość zaludnienia", unit: "osoba/km²", variable_id: 60559, category: "demographics", level: "powiat", values: { "2024": 3500 } }, + unemployment_rate: { name: "Stopa bezrobocia", unit: "%", variable_id: 60270, category: "economy", level: "powiat", values: { "2024": 3.2 } }, + higher_education_pct: { name: "% z wyższym wykształceniem", unit: "%", variable_id: null, category: "education", level: "powiat", values: { "2021": 45.6 }, derived: true, snapshot: true }, + }, + meta: { variables_count: 3, categories: ["demographics", "economy", "education"], levels_included: ["powiat"], data_source: "GUS BDL (Bank Danych Lokalnych)", as_of: "2024" }, + }; + + it("groups by category and surfaces the location header + as_of", () => { + const out = formatDemographics(base); + expect(out).toContain("Warszawa (powiat, teryt 1465)"); + expect(out).toContain("as of 2024"); + expect(out).toContain("Demographics"); + expect(out).toContain("Economy"); + expect(out).toContain("Education"); + expect(out).toContain("Gęstość zaludnienia"); + }); + + it("flags derived + snapshot indicators", () => { + const out = formatDemographics(base); + expect(out).toContain("[derived, snapshot]"); + }); + + it("compacts a >5-year time series to latest + span", () => { + const ts: DemographicsResponse = { + ...base, + indicators: { + population_density: { + name: "Gęstość zaludnienia", unit: "osoba/km²", variable_id: 60559, category: "demographics", level: "powiat", + values: { "2018": 3400, "2019": 3420, "2020": 3450, "2021": 3470, "2022": 3480, "2023": 3490, "2024": 3500 }, + }, + }, + }; + const out = formatDemographics(ts); + expect(out).toContain("7 yrs 2018→2024"); + expect(out).toContain("from 3400"); // span start + }); + + it("renders a readable message for coverage:no_data and never throws", () => { + const empty: DemographicsResponse = { + location: { name: null, country_code: "PL", location_type: "county", teryt: "9999", level: "powiat", hierarchy: {} }, + coverage: "no_data", + indicators: {}, + meta: { variables_count: 0, categories: [], levels_included: [], data_source: "GUS BDL (Bank Danych Lokalnych)", as_of: null }, + }; + const out = formatDemographics(empty); + expect(out).toContain("No GUS BDL indicators are available"); + expect(out).toContain("9999"); + }); + + it("only names GUS BDL — never a source brand", () => { + const out = formatDemographics(base).toLowerCase(); + expect(out).toContain("gus bdl"); + expect(findGuardToken(out)).toBeNull(); + }); +}); + +describe("formatCompareResults — demographics enrichment", () => { + it("renders demographics per district and tolerates a district without the key", () => { + const res: CompareResponse = { + "Mokotów": { + median_price_m2: 15200, avg_area: 58.3, min_date: "2024-01-01", max_date: "2024-12-31", total: 1234, + demographics: { + unemployment_rate: { value: 3.2, year: 2024, unit: "%" }, + price_to_income_years: { value: 14.5, year: null, unit: "years", derived: true, cross_source: true }, + }, + }, + "Wola": { median_price_m2: 12100, avg_area: 45.0, min_date: "2024-02-15", max_date: "2024-11-30", total: 987 }, + }; + const out = formatCompareResults(res); + expect(out).toContain("Demographics (GUS BDL"); + expect(out).toContain("unemployment_rate"); + expect(out).toContain("[derived, cross-source]"); + expect(out).toContain("no demographic data for Wola"); + }); + + it("shows no demographics section when no district carries the key", () => { + const res: CompareResponse = { + "Mokotów": { median_price_m2: 15200, avg_area: 58.3, min_date: "2024-01-01", max_date: "2024-12-31", total: 1234 }, + "Wola": { median_price_m2: 12100, avg_area: 45.0, min_date: "2024-02-15", max_date: "2024-11-30", total: 987 }, + }; + const out = formatCompareResults(res); + expect(out).not.toContain("Demographics (GUS BDL"); + }); +}); + +// ── Parcel report ────────────────────────────────────────────────── + +describe("formatParcelReport", () => { + function makeReport(overrides: Partial = {}): ParcelReportResponse { + const base: ParcelReportResponse = { + parcel: { + id: "uuid-1", parcel_id: "141201_1.0001.123/4", parcel_key: "141201_1.0001.123-4", + district: "Śródmieście", county_name: "Warszawa", voivodeship_name: "mazowieckie", + area_m2: 850, land_use: "B", mpzp_designation: null, has_geometry: true, + centroid: { lat: 52.23, lng: 21.01 }, + }, + coverage: "covered", + as_of: "2026-05-01T00:00:00.000Z", + sections: { + transactions: { coverage: "covered", as_of: "2026-05-01", total: 3, truncated: false, data: [ + { transaction_date: "2024-11-15", property_type: 4, market_type: 2, price_gross: 890000, usable_area_m2: 62.5, price_per_m2: 14240 }, + ] }, + flood: { coverage: "covered", as_of: "2026-04-01", flood_risk: "medium", pct_in_zone: 40 }, + heritage: { coverage: "covered_no_data", as_of: null, heritage_status: null, site_count: null }, + landslide: { coverage: "not_covered", as_of: null, landslide_risk: null }, + surroundings: { coverage: "covered", as_of: "2026-04-01", cemetery_distance_m: 320, landfill_distance_m: null, sewage_treatment_distance_m: 1200, industrial_area_distance_m: null, industrial_plant_distance_m: null, livestock_farm_distance_m: null }, + transit: { coverage: "covered", as_of: "2026-04-01", bus_distance_m: 150, tram_distance_m: 600, rail_distance_m: null, metro_distance_m: null }, + planning: { coverage: "covered", as_of: "2026-03-01", data: [{ zone_symbol: "MW", zone_name: "zabudowa mieszkaniowa" }], truncated: false }, + buildings: { coverage: "covered", as_of: "2026-02-01", data: [{}, {}], truncated: false }, + permits: { coverage: "not_computed", as_of: null, data: [], truncated: false }, + farmland: { coverage: "covered_no_data", as_of: null, eligible_area_m2: null, pct_of_parcel: null, feature_count: null }, + market_context: { + coverage: "full", as_of: null, + county: { coverage: "full", median_price_per_m2: 15200, n: 4200, county_code: "1412" }, + locality: { coverage: "low_sample", median_price_per_m2: 16100, n: 12, district: "Śródmieście" }, + }, + location_context: { + coverage: "full", as_of: "2025-12-31", gmina_teryt: "141201", + demographics: { coverage: "full", as_of: "2025-12-31", name: "Warszawa", indicators: { + population: { name: "Population", unit: "persons", variable_id: 1, category: "demographics", level: "gmina", values: { "2024": 1861599 } }, + } }, + infra_signals: { coverage: "partial", as_of: "2026-01-15", gmina_name: "Warszawa", + tenders: { window_months: 12, by_category: { roads: 3, water: 1 }, recent: [], truncated: false }, + kposk: { in_agglomeration: true, agglomerations: [], truncated: false }, + capex: { by_year: {} } }, + }, + }, + billing: { charged: 35, refunded: 0, rule: "full" }, + note: "A composite parcel dossier.", + }; + return { ...base, ...overrides }; + } + + it("renders the core, every layer's explicit four-state, and the full-billing footer", () => { + const out = formatParcelReport(makeReport()); + expect(out).toContain("Parcel report: 141201_1.0001.123/4"); + expect(out).toContain("Śródmieście, Warszawa, mazowieckie"); + expect(out).toContain("Core: covered (as of 2026-05-01)"); + // Four-state explicit per layer. + expect(out).toContain("Flood risk: covered — medium risk, 40% of the parcel in the mapped zone"); + expect(out).toContain("Heritage listing: covered_no_data (checked — nothing found, still billed)"); + expect(out).toContain("Landslide risk: not_covered (outside our data — refunded)"); + expect(out).toContain("Building activity: not_computed (could not finish in time — refunded, retry)"); + expect(out).toContain("Nuisance surroundings: covered — cemetery 320 m, sewage treatment 1200 m"); + expect(out).toContain("Public transport: covered — bus 150 m, tram 600 m"); + expect(out).toContain("Planning (general plan): covered — zones: MW"); + expect(out).toContain("Buildings: covered — 2 building(s) on the parcel"); + // Transaction history + price context + municipal context. + expect(out).toContain("Transaction history: covered"); + expect(out).toContain("- County:"); + expect(out).toContain("(n=4200)"); + expect(out).toContain("[small sample]"); // locality low_sample flag + expect(out).toContain("Municipal context (gmina 141201)"); + expect(out).toContain("in a collective-sewerage agglomeration"); + // Billing footer. + expect(out).toContain("Billing: 35 charged, 0 refunded — billed in full"); + }); + + it("renders the core-floor billing explanation with partial refund", () => { + const out = formatParcelReport(makeReport({ billing: { charged: 1, refunded: 34, rule: "core_floor" } })); + expect(out).toContain("Billing: 1 charged, 34 refunded — resolved, but no enrichment layer had data"); + }); + + it("short-circuits a total miss to a one-line refund message", () => { + const out = formatParcelReport(makeReport({ + parcel: { id: null, parcel_id: "999999_9.9999.9/9", parcel_key: "999999_9.9999.9-9" }, + coverage: "not_covered", + billing: { charged: 0, refunded: 35, rule: "total_miss_refund" }, + })); + expect(out).toContain("could not be resolved"); + expect(out).toContain("Billing: 0 charged, 35 refunded — fully refunded"); + expect(out).not.toContain("Enrichment layers:"); + }); + + it("disabled kill-switch → its own header (not the transient 'retry' one)", () => { + const out = formatParcelReport(makeReport({ + coverage: "not_computed", + billing: { charged: 0, refunded: 35, rule: "disabled" }, + })); + expect(out).toContain("temporarily unavailable"); + expect(out).not.toContain("a live lookup did not finish"); + expect(out).toContain("Billing: 0 charged, 35 refunded — fully refunded — the composite report is temporarily unavailable"); + expect(out).not.toContain("Enrichment layers:"); + }); + + it("a transient not_computed core → the 'retry' header (distinct from disabled)", () => { + const out = formatParcelReport(makeReport({ + parcel: { id: null, parcel_id: "141201_1.0001.123/4", parcel_key: "141201_1.0001.123-4" }, + coverage: "not_computed", + billing: { charged: 0, refunded: 35, rule: "not_computed_refund" }, + })); + expect(out).toContain("a live lookup did not finish — retry"); + expect(out).not.toContain("temporarily unavailable"); + }); + + it("suppresses a median with too few sales", () => { + const r = makeReport(); + r.sections.market_context.county = { coverage: "suppressed", median_price_per_m2: null, n: 3, county_code: "1412" }; + const out = formatParcelReport(r); + expect(out).toContain("County: withheld (only 3 sale(s) — too few to publish)"); + }); +}); diff --git a/src/__tests__/guard-tokens.ts b/src/__tests__/guard-tokens.ts new file mode 100644 index 0000000..1ddbdc0 --- /dev/null +++ b/src/__tests__/guard-tokens.ts @@ -0,0 +1,125 @@ +/** + * Terms that must never reach the published artifact: the registers and platforms behind our + * data, and a few internal field names. + * + * They are stored encoded on purpose. This repository is public, and the guards that assert + * "the output must not contain X" are themselves a place where X is written down — a plaintext + * list here would hand a reader the very index the guards exist to prevent. Encoding costs one + * decode per test run and keeps the guards load-bearing. + * + * Adding a term: `printf '%s' "" | base64` and append it, lowercase, to the right group. + */ + +const decode = (values: readonly string[]): string[] => + values.map((v) => Buffer.from(v, "base64").toString("utf8")); + +/** Commercial platforms whose data or name must not surface. */ +export const PLATFORM_TOKENS = decode([ + "aG9tZXNjYW4=", + "b3RvZG9t", +]); + +/** Public registers and agencies we read from. Describe the result, never the source. */ +export const SOURCE_TOKENS = decode([ + "Z3VnaWs=", + "bmlk", + "YmRvdA==", + "ZWdpYg==", + "YnViZA==", + "Z3VuYg==", + "cndkeg==", + "YXJpbXI=", + "bHBpcw==", + "bWtv", + "anBv", + "dWxkaw==", + "ZXppdWRw", + "aWdlb21hcA==", + "Z2VvLXN5c3RlbQ==", + "bmJw", + "Z2VvcG9ydGFs", + "dG9wb2dyYWY=", + "emFieXRlaw==", + "d3Vveg==", + "bWt1cmFu", + "cG9saXNoX3RyYWlucw==", + "enRt", + "cGtw", + "Z3pt", +]); + +/** + * Notation and category labels peculiar to a source dataset. Not names, but a fingerprint: quote + * them back and a reader knows which dataset we read, which is the thing we do not say. + */ +export const NOTATION_TOKENS = decode([ + "Zmx1dmlhbA==", + "c2Vhd2F0ZXI=", + "c2NlbmFyaXVzeg==", + "cTEw", + "cTEl", + "cSAx", + "cTAuMg==", + "d3lzenVraXdhcmth", + "cHJhd28gYnVkb3dsYW5l", + "aW5zcGlyZQ==", + "d2Zz", +]); + +/** Internal column names and prefixes that would describe our storage layout. */ +export const INTERNAL_FIELD_TOKENS = decode([ + "ZmFybWxhbmRfbWtv", + "cGFyY2VsX3JlZg==", + "YXJlYV9nZW9tX20y", + "aHNf", +]); + +export const ALL_GUARD_TOKENS = [ + ...PLATFORM_TOKENS, + ...SOURCE_TOKENS, + ...NOTATION_TOKENS, + ...INTERNAL_FIELD_TOKENS, +]; + +/** + * Markers of the private repository this package is developed in: internal document sections, + * the monorepo directory, plan paths, deployment host names. Encoded for the same reason as the + * rest — spelled out here, the list would name our hosts and internal layout in a public file. + */ +export const INTERNAL_MARKERS = decode([ + "Y29udmVudGlvbnMgwqc=", + "bmllcnVjaG9tb3NjaV9jbGF1ZGU=", + "cGxhbnMvYWN0aXZl", + "cGxhbnMvYXJjaGl2ZQ==", + "Y3g0Mw==", + "Y2F4MTE=", +]); + +const escape = (t: string): string => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Most tokens are matched as substrings, so inflected and suffixed forms still trip the guard. +// Short all-letter tokens need a boundary — matched loosely, a three-letter acronym fires on +// ordinary identifiers (one of them is a substring of "transactionId"), and a guard that cries +// wolf is a guard someone eventually deletes. +// +// A leading boundary is enough, and only a leading one is safe. \b will not do: it counts "_" as a +// word character, so \b...\b lets the acronym through as the head of a snake_case field name — +// exactly a thing we guard against. A trailing (?![a-z]) is worse than useless here: under the /i +// flag [a-z] matches uppercase too, so the "C" of a camelCase suffix suppresses the match, and +// camelCase is the dominant identifier shape in this package. So: a preceding letter or digit means +// the hit is incidental and we ignore it; anything that follows is fair game. +// +// If this ever fires on an innocent word — a place name sharing a token's first three letters is +// the likely one — narrow that token or add the specific word as an exception. Do NOT restore a +// trailing (?![a-z]): it looks like the fix and silently reopens every camelCase form. +const asPattern = (t: string): string => + t.length <= 3 && /^[a-z]+$/.test(t) ? `(? + text.match(GUARD_PATTERN)?.[0] ?? null; diff --git a/src/__tests__/leak-guard.test.ts b/src/__tests__/leak-guard.test.ts new file mode 100644 index 0000000..82b31f7 --- /dev/null +++ b/src/__tests__/leak-guard.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { GUARD_PATTERN, findGuardToken } from "./guard-tokens.js"; + +// Filesystem leak-guard: this whole directory is copied verbatim to the public repository, and the +// build keeps whatever the sources say, so a guarded term in a comment, string or description ships. +// +// The scan covers everything, tests included. Earlier versions skipped __tests__/ on the belief that +// the public copy excluded it. It does not — the public repository tracks the tests, so guards that +// spell out what they forbid publish the very index they exist to prevent. That is why the terms +// live encoded in ./guard-tokens.ts and why nothing here is exempt from the sweep. + +const here = dirname(fileURLToPath(import.meta.url)); // .../mcp-server/src/__tests__ +const rootDir = join(here, "..", ".."); // .../mcp-server + +// No file exemptions. The lockfile used to be skipped because its base64 integrity hashes collided +// with short tokens by chance — but that was only true of unbounded matching, and the boundary +// rules in ./guard-tokens.ts removed the collisions. The lockfile travels to the public repository +// like everything else, and a linked dependency can write an absolute path into it, so it is +// scanned. An exemption here needs the same treatment SKIP_DIRS gets below: a rule, not a rationale. + +// Generated or never-copied. Each one must ALSO be ignored by this directory's own .gitignore — +// otherwise skipping it here would hide a directory that really does travel to the public +// repository, which is the failure this whole file exists to prevent. The test below enforces +// that; do not add an entry without adding the matching rule. +const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "coverage", ".turbo"]); + +function collectFiles(dir: string, skip: Set = new Set()): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (skip.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...collectFiles(full, skip)); + else out.push(full); + } + return out; +} + +/** + * Paths this directory's OWN .gitignore files suppress. + * + * The distinction matters and is easy to get wrong. Asking git plainly whether a path is ignored + * answers it in the context of the repository we develop in — whose root .gitignore does NOT travel + * when this directory is copied out. A file hidden only by that outer rule looks safe here and is + * committed for real over there. `check-ignore -v` names the rule's source file, so we can keep the + * rules that travel and disregard the ones that do not. + */ +function locallyIgnored(files: string[]): Set { + // "" when this directory is the repository root, as it is in the public copy. + const prefix = execFileSync("git", ["rev-parse", "--show-prefix"], { + cwd: rootDir, + encoding: "utf8", + }).trim(); + + let out: string; + try { + out = execFileSync("git", ["check-ignore", "-v", "-z", "--stdin"], { + cwd: rootDir, + encoding: "utf8", + input: files.map((f) => relative(rootDir, f)).join("\0"), + }); + } catch (err) { + // Exit code 1 means "nothing matched" and comes with empty output — not an error for us. + const e = err as { status?: number; stdout?: string }; + if (e.status !== 1) throw err; + out = e.stdout ?? ""; + } + + const ignored = new Set(); + const fields = out.split("\0"); + // Records are (source, linenum, pattern, pathname); the trailing element after the last NUL is "". + for (let i = 0; i + 3 < fields.length; i += 4) { + const source = fields[i] ?? ""; + const pathname = fields[i + 3] ?? ""; + if (source.startsWith(prefix)) ignored.add(join(rootDir, pathname)); + } + return ignored; +} + +/** + * Every file that travels when this directory is copied to the public repository, whether or not + * it is tracked here yet: a stray file git would happily commit is exactly what we need to see. + */ +function publishedFiles(): string[] { + const onDisk = collectFiles(rootDir, SKIP_DIRS); + const ignored = locallyIgnored(onDisk); + return onDisk.filter((f) => !ignored.has(f)); +} + +const offendersIn = (files: string[]): string[] => + files + .filter((f) => GUARD_PATTERN.test(readFileSync(f, "utf8"))) + .map((f) => `${relative(rootDir, f)} (${findGuardToken(readFileSync(f, "utf8"))})`); + +describe("leak-guard (published surface)", () => { + it("no guarded term appears in any file that would reach the public repository", () => { + // Not an allowlist of files we remembered to list: everything a reader of the public + // repository can open, including configs, scripts and the tests themselves. + const files = publishedFiles(); + expect(files.length, "git listed no files — the scan would pass vacuously").toBeGreaterThan(20); + const offenders = offendersIn(files); + expect(offenders, `guarded term found in: ${offenders.join(", ")}`).toEqual([]); + }); + + it("every skipped directory is one this directory's own .gitignore suppresses", () => { + // Guards the assumption SKIP_DIRS rests on. A skipped directory that nothing ignores is a + // directory git would commit to the public repository while the sweep above looks away — + // and the mistake reads as a passing test, which is why it needs to be asserted, not commented. + const dirs = [...SKIP_DIRS].filter((d) => d !== ".git"); // git ignores .git itself, no rule needed + const probes = dirs.map((d) => join(rootDir, d, "probe.txt")); + const ignored = locallyIgnored(probes); + const unguarded = dirs.filter((d) => !ignored.has(join(rootDir, d, "probe.txt"))); + expect( + unguarded, + `skipped but not ignored by mcp-server/.gitignore: ${unguarded.join(", ")}`, + ).toEqual([]); + }); + + it("no guarded term reaches dist/ when built", () => { + const distDir = join(rootDir, "dist"); + if (!existsSync(distDir)) return; // dist is only present after a build; skip when absent + const offenders = offendersIn(collectFiles(distDir)); + expect(offenders, `guarded term found in: ${offenders.join(", ")}`).toEqual([]); + }); +}); diff --git a/src/__tests__/sentry-scrub.test.ts b/src/__tests__/sentry-scrub.test.ts new file mode 100644 index 0000000..50aa763 --- /dev/null +++ b/src/__tests__/sentry-scrub.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { scrubHeaders, scrubString } from "../sentry-scrub.js"; + +describe("scrubHeaders", () => { + it("filters sensitive headers", () => { + const result = scrubHeaders({ + "authorization": "Bearer eyJhbG...", + "cookie": "session=abc123", + "x-internal-auth": "secret", + "x-api-key": "cngrm_abc123", + "content-type": "application/json", + "user-agent": "claude-desktop/1.0", + }); + expect(result.authorization).toBe("[Filtered]"); + expect(result.cookie).toBe("[Filtered]"); + expect(result["x-internal-auth"]).toBe("[Filtered]"); + expect(result["x-api-key"]).toBe("[Filtered]"); + expect(result["content-type"]).toBe("application/json"); + expect(result["user-agent"]).toBe("claude-desktop/1.0"); + }); + + it("is case-insensitive", () => { + const result = scrubHeaders({ + "Authorization": "Bearer token", + "X-Internal-Auth": "secret", + }); + expect(result.Authorization).toBe("[Filtered]"); + expect(result["X-Internal-Auth"]).toBe("[Filtered]"); + }); +}); + +describe("scrubString", () => { + it("replaces cngrm_ API key patterns", () => { + expect(scrubString("https://cenogram.pl/api?key=cngrm_c4678d5d80203972a43fa721b770a44b")) + .toBe("https://cenogram.pl/api?key=[Filtered]"); + }); + + it("replaces keys in error messages", () => { + expect(scrubString("fetch failed for cngrm_aaa111bbb222")) + .toBe("fetch failed for [Filtered]"); + }); + + it("replaces multiple keys", () => { + expect(scrubString("cngrm_aaa111 and cngrm_bbb222")) + .toBe("[Filtered] and [Filtered]"); + }); + + it("leaves non-matching strings unchanged", () => { + expect(scrubString("Internal server error")).toBe("Internal server error"); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index ffc3a62..1ac870c 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,17 +1,54 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest"; import { spawn, type ChildProcess } from "node:child_process"; -import { symlinkSync, unlinkSync, existsSync } from "node:fs"; +import { symlinkSync, unlinkSync, existsSync, readFileSync, readdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { createServer } from "node:net"; import { fetch } from "undici"; import { generateKeyPair, exportSPKI, SignJWT } from "jose"; import type { CryptoKey } from "jose"; -import { createMcpServer } from "../index.js"; +import { createMcpServer, serverInstructions } from "../index.js"; +import { signupUrl } from "../error-messages.js"; import { startStubApi, type StubApi } from "./fixtures/stub-api.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const distIndex = join(__dirname, "..", "..", "dist", "index.js"); +// Ask the kernel for a genuinely-free port instead of guessing a random one +// (Math.random ranges collided under load → EADDRINUSE). The two HTTP describe +// blocks run sequentially within this single file (vitest doesn't parallelise +// describe blocks in one file, no .concurrent here), so the close→spawn window +// of one never overlaps the other → no TOCTOU within the run. +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("could not acquire a free port"))); + } + }); + }); +} + +// Kill a spawned server and WAIT for the OS to actually reap it, so the kernel +// releases the bound port before the next describe block binds. Guards against a +// process that already exited (exit event would never re-fire → hung promise) and +// caps the wait so a stuck process can't wedge the suite. +async function killAndWait(p: ChildProcess | undefined): Promise { + if (!p || p.killed || p.exitCode !== null || p.signalCode !== null) return; + p.kill("SIGTERM"); + await Promise.race([ + new Promise((r) => p.once("exit", () => r())), + new Promise((r) => setTimeout(r, 5000)), + ]); +} + describe("createMcpServer", () => { it("returns server with correct name and version", () => { const server = createMcpServer("test-key"); @@ -20,6 +57,57 @@ describe("createMcpServer", () => { }); }); +// Every cenogram.pl link we hand a client carries a `?src=` tag that must match the active +// transport. Both transports serve the same source text, so a tag written as a literal is silently +// wrong for one of them - invisible at runtime, hence asserted here rather than described in a +// comment next to the link. +describe("channel attribution", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("tags links as stdio when no HTTP transport is set", () => { + expect(serverInstructions()).toContain("?src=mcpstdio"); + expect(signupUrl()).toContain("?src=mcpstdio"); + }); + + it("tags links as hosted HTTP, with no stdio tag left behind", () => { + vi.stubEnv("MCP_TRANSPORT", "http"); + const instructions = serverInstructions(); + expect(instructions).toContain("?src=mcphttp"); + expect(instructions).not.toContain("mcpstdio"); + expect(signupUrl()).toBe("https://cenogram.pl/api?src=mcphttp"); + }); + + // The two tests above only cover the links they name, and the first version of this fix tagged + // exactly those, and four other links to the same page kept going out untagged. So this one asks + // the opposite question — find every cenogram.pl link in the package and demand a tag — because a + // test that names the links it checks can only ever confirm the ones somebody already thought of. + it("every cenogram.pl link handed to a client carries a channel tag", () => { + // Nothing anyone lands on from a link we emit, so a tag would measure nothing: + // /ustawienia - behind a login, only ever shown to someone already registered + // apple-touch-icon.png - an asset in OAuth metadata, never navigated to + // (The bare origin needs no exemption: the pattern below requires a path.) + const EXEMPT = [/\/ustawienia\b/, /apple-touch-icon/]; + // Every source file, not a list of the ones that have a link today - a new file with a new + // link is the case this test is for, and it would be the one case a fixed list misses. + const srcDir = join(__dirname, ".."); + const sources = readdirSync(srcDir).filter((f) => f.endsWith(".ts")); + + const untagged: string[] = []; + for (const file of sources) { + const text = readFileSync(join(srcDir, file), "utf8"); + // Stops at whitespace, a quote or a backtick — i.e. at the end of the literal, so a URL + // built from a `${...}` expression is captured with the expression intact. + for (const [url] of text.matchAll(/https:\/\/cenogram\.pl\/[^\s"'`,)]+/g)) { + if (EXEMPT.some((re) => re.test(url))) continue; + if (!url.includes("src=")) untagged.push(`${file}: ${url}`); + } + } + expect(untagged, `cenogram.pl links with no ?src= tag:\n${untagged.join("\n")}`).toEqual([]); + }); +}); + const hasDistBuild = existsSync(distIndex); describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => { @@ -53,7 +141,7 @@ describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => const readResponse = () => new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Timeout waiting for server response")), 5000); + const timeout = setTimeout(() => reject(new Error("Timeout waiting for server response")), 20000); proc.stdout!.on("data", (chunk: Buffer) => { buffer += chunk.toString(); // MCP responses are newline-delimited JSON @@ -82,7 +170,7 @@ describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => } it("responds to initialize when run directly", async () => { - const { send, readResponse, kill } = spawnServer(distIndex); + const { proc, send, readResponse } = spawnServer(distIndex); try { send({ jsonrpc: "2.0", @@ -101,12 +189,16 @@ describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => expect(response.id).toBe(1); expect(serverInfo.name).toBe("cenogram-mcp-server"); } finally { - kill(); + await killAndWait(proc); } - }); + // 30s timeout: spawn (node boot + dist load) under pre-push load (run_all.sh + // oversubscribes cores with 6+ suites) can exceed the 5s default; must also clear + // readResponse's internal 20s timeout (above) with a buffer. Mitigation, not a + // contention fix (serialization = out of scope). + }, 30000); it("responds to initialize when run via symlink (like npx)", async () => { - const { send, readResponse, kill } = spawnServer(symlinkPath); + const { proc, send, readResponse } = spawnServer(symlinkPath); try { send({ jsonrpc: "2.0", @@ -125,9 +217,9 @@ describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => expect(response.id).toBe(1); expect(serverInfo.name).toBe("cenogram-mcp-server"); } finally { - kill(); + await killAndWait(proc); } - }); + }, 30000); // see timeout note above (spawn under pre-push load > 5s, > internal 20s) it("exits with error when CENOGRAM_API_KEY is missing", async () => { const proc = spawn(process.execPath, [distIndex], { @@ -140,14 +232,17 @@ describe.skipIf(!hasDistBuild)("stdio server via symlink (npx scenario)", () => }); expect(exitCode).toBe(1); - }); + }, 30000); // see timeout note above (process spawn under pre-push load > 5s default) }); describe.skipIf(!hasDistBuild)("HTTP mode auth dispatch (E2E spawn)", () => { let proc: ChildProcess; let port: number; - async function waitForPort(p: number, timeoutMs = 5000): Promise { + // 20s: node boot under pre-push load (6 suites oversubscribing 8 cores) can exceed + // the old 5s. The loop polls /health (readiness, not a fixed sleep) so a fast boot + // still returns immediately; the larger budget only matters when the box is thrashing. + async function waitForPort(p: number, timeoutMs = 20000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { @@ -162,7 +257,7 @@ describe.skipIf(!hasDistBuild)("HTTP mode auth dispatch (E2E spawn)", () => { } beforeAll(async () => { - port = 33000 + Math.floor(Math.random() * 1000); + port = await getFreePort(); proc = spawn(process.execPath, [distIndex], { env: { ...process.env, @@ -174,10 +269,10 @@ describe.skipIf(!hasDistBuild)("HTTP mode auth dispatch (E2E spawn)", () => { stdio: ["pipe", "pipe", "pipe"], }); await waitForPort(port); - }, 10_000); + }, 30_000); - afterAll(() => { - if (proc && !proc.killed) proc.kill("SIGTERM"); + afterAll(async () => { + await killAndWait(proc); }); it("/health returns 200 ok", async () => { @@ -227,7 +322,8 @@ describe.skipIf(!hasDistBuild)("HTTP mode E2E (JWT + stub upstream)", () => { const KID = "test-kid"; let stderrBuf = ""; - async function waitForPort(p: number, timeoutMs = 8000): Promise { + // 20s: see the auth block's waitForPort — boot under pre-push CPU load. + async function waitForPort(p: number, timeoutMs = 20000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { @@ -264,7 +360,7 @@ describe.skipIf(!hasDistBuild)("HTTP mode E2E (JWT + stub upstream)", () => { stub = await startStubApi(); // 3. Spawn MCP HTTP with OAuth + stub URL - port = 34000 + Math.floor(Math.random() * 1000); + port = await getFreePort(); proc = spawn(process.execPath, [distIndex], { env: { ...process.env, @@ -279,10 +375,10 @@ describe.skipIf(!hasDistBuild)("HTTP mode E2E (JWT + stub upstream)", () => { }); proc.stderr?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString("utf-8"); }); await waitForPort(port); - }, 15_000); + }, 30_000); afterAll(async () => { - if (proc && !proc.killed) proc.kill("SIGTERM"); + await killAndWait(proc); if (stub) await stub.close(); }); @@ -349,12 +445,14 @@ describe.skipIf(!hasDistBuild)("HTTP mode E2E (JWT + stub upstream)", () => { expectInText: ["Connection to Cenogram expired", "Connectors > Cenogram", "disconnect and reconnect"], }, { - name: "503: maintenance mode EN", + // Body-less 503, so there is nothing to relay and the generic wording applies. It no + // longer claims "maintenance": a 503 is just as often a disabled feature or a failover. + name: "503: temporarily unavailable EN", setScenario: () => stub.setScenarios({ byBearerToken: { "cngrm_test_503": { status: 503 } }, }), auth: "Bearer cngrm_test_503", - expectInText: ["unavailable", "maintenance", "Try again"], + expectInText: ["unavailable", "Try again"], }, { name: "403 email_not_verified: inbox check EN", diff --git a/src/__tests__/tool-handlers.test.ts b/src/__tests__/tool-handlers.test.ts index 6ae0ff5..5d9caae 100644 --- a/src/__tests__/tool-handlers.test.ts +++ b/src/__tests__/tool-handlers.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; +import { findGuardToken } from "./guard-tokens.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { @@ -8,12 +9,15 @@ import type { PricePerM2Row, HistogramBin, ParcelSearchResponse, + ParcelResolveResponse, SpatialSearchResponse, CompareResponse, CreditInfo, ApiResponse, LocationItem, + ValuationResponse, } from "../api-client.js"; +import { encodeOAuthCtx } from "../api-client.js"; // ── Mock api-client (replaces entire module, including module-level API_KEY) ── @@ -25,10 +29,33 @@ const mockGetDistricts = vi.fn(); const mockGetLocations = vi.fn(); const mockGetPriceHistogram = vi.fn(); const mockSearchParcels = vi.fn(); +const mockResolveParcel = vi.fn(); const mockSearchByPolygon = vi.fn(); const mockCompareLocations = vi.fn(); - -vi.mock("../api-client.js", () => ({ +const mockGetRentalYield = vi.fn(); +const mockGetRentalYieldLocations = vi.fn(); +const mockGetPriceSpread = vi.fn(); +const mockGetPriceSpreadLocations = vi.fn(); +const mockGetValuation = vi.fn(); +const mockGetBuildingBreakdown = vi.fn(); +const mockGetTransactionFlood = vi.fn(); +const mockGetTransactionHeritage = vi.fn(); +const mockGetTransactionLandslide = vi.fn(); +const mockGetTransactionSurroundings = vi.fn(); +const mockGetTransactionTransit = vi.fn(); +const mockGetTransactionFarmland = vi.fn(); +const mockGetDemographics = vi.fn(); +const mockGetInfrastructureSignals = vi.fn(); + +// tools.ts imports decodeOAuthCtx + OAUTH_CTX_PREFIX from api-client for identity logging, and the +// identity tests below build OAuth keys via encodeOAuthCtx — pass those three through from the real +// module so the mock doesn't shadow them (otherwise decodeOAuthCtx is undefined → every tool call throws). +vi.mock("../api-client.js", async () => { + const actual = await vi.importActual("../api-client.js"); + return { + encodeOAuthCtx: actual.encodeOAuthCtx, + decodeOAuthCtx: actual.decodeOAuthCtx, + OAUTH_CTX_PREFIX: actual.OAUTH_CTX_PREFIX, getTransactions: (...args: unknown[]) => mockGetTransactions(...args), getTransactionsSummary: (...args: unknown[]) => mockGetTransactionsSummary(...args), getStats: (...args: unknown[]) => mockGetStats(...args), @@ -37,14 +64,42 @@ vi.mock("../api-client.js", () => ({ getLocations: (...args: unknown[]) => mockGetLocations(...args), getPriceHistogram: (...args: unknown[]) => mockGetPriceHistogram(...args), searchParcels: (...args: unknown[]) => mockSearchParcels(...args), + resolveParcel: (...args: unknown[]) => mockResolveParcel(...args), searchByPolygon: (...args: unknown[]) => mockSearchByPolygon(...args), compareLocations: (...args: unknown[]) => mockCompareLocations(...args), -})); + getRentalYield: (...args: unknown[]) => mockGetRentalYield(...args), + getRentalYieldLocations: (...args: unknown[]) => mockGetRentalYieldLocations(...args), + getPriceSpread: (...args: unknown[]) => mockGetPriceSpread(...args), + getPriceSpreadLocations: (...args: unknown[]) => mockGetPriceSpreadLocations(...args), + getValuation: (...args: unknown[]) => mockGetValuation(...args), + getBuildingBreakdown: (...args: unknown[]) => mockGetBuildingBreakdown(...args), + getTransactionFlood: (...args: unknown[]) => mockGetTransactionFlood(...args), + getTransactionHeritage: (...args: unknown[]) => mockGetTransactionHeritage(...args), + getTransactionLandslide: (...args: unknown[]) => mockGetTransactionLandslide(...args), + getTransactionSurroundings: (...args: unknown[]) => mockGetTransactionSurroundings(...args), + getTransactionTransit: (...args: unknown[]) => mockGetTransactionTransit(...args), + getTransactionFarmland: (...args: unknown[]) => mockGetTransactionFarmland(...args), + getDemographics: (...args: unknown[]) => mockGetDemographics(...args), + getInfrastructureSignals: (...args: unknown[]) => mockGetInfrastructureSignals(...args), + }; +}); vi.mock("../client-id.js", () => ({ getClientId: () => "test-client-uuid", })); +// Fake Sentry so the identity tests can assert per-call setUser + captureException. withScope must +// return the callback's result (a Promise) so `return await Sentry.withScope(...)` in tools.ts works. +const mockSentrySetUser = vi.fn(); +const mockSentryCaptureException = vi.fn(); +vi.mock("../sentry.js", () => ({ + Sentry: { + withScope: (cb: (scope: { setUser: (u: { id: string }) => void }) => unknown) => + cb({ setUser: (u: { id: string }) => mockSentrySetUser(u) }), + captureException: (...args: unknown[]) => mockSentryCaptureException(...args), + }, +})); + // ── Test fixtures ────────────────────────────────────────────────── const creditInfo: CreditInfo = { balance: 48, cost: 2 }; @@ -117,6 +172,20 @@ const sampleParcels: ParcelSearchResponse = { ], }; +const sampleParcelResolve: ParcelResolveResponse = { + query: { mode: "q", q: "Wawer 27" }, + coverage: "covered", + as_of: "2026-06-30T00:00:00.000Z", + matches: [ + { + id: "uuid-1", parcel_id: "146518_8.0108.27", parcel_key: "146518_8.0108.27", + district: "Wawer", county_code: "1465", parcel_number: "27", area_m2: 1200, + has_geometry: true, centroid: { lat: 52.1234, lng: 21.0567 }, + }, + ], + truncated: false, +}; + const sampleSpatialResponse: SpatialSearchResponse = { type: "FeatureCollection", features: [{ @@ -177,9 +246,36 @@ function getTextContent(result: unknown): string { // ── Tests: Tool discovery ────────────────────────────────────────── describe("tool discovery", () => { - it("lists exactly 9 tools", async () => { + it("registers exactly this set of tools by default", async () => { + // The published package is what a stranger sees, so the default set is a release + // decision, not an implementation detail. Asserted by name rather than by count: a + // count survives one tool silently replacing another. const { tools } = await client.listTools(); - expect(tools).toHaveLength(9); + expect(tools.map((t) => t.name).sort()).toEqual([ + "compare_locations", + "estimate_value", + "get_building_breakdown", + "get_demographics", + "get_infrastructure_signals", + "get_market_overview", + "get_parcel_report", + "get_price_distribution", + "get_price_statistics", + "get_transaction_farmland", + "get_transaction_flood", + "get_transaction_heritage", + "get_transaction_landslide", + "get_transaction_permits", + "get_transaction_planning", + "get_transaction_surroundings", + "get_transaction_transit", + "list_locations", + "resolve_parcel", + "search_by_area", + "search_by_polygon", + "search_parcels", + "search_transactions", + ]); }); it("all tools have readOnlyHint annotation", async () => { @@ -189,21 +285,88 @@ describe("tool discovery", () => { } }); - it("has all expected tool names", async () => { + it("all tools have title + destructiveHint:false annotations", async () => { + // Anthropic Connectors Directory (Software Directory Policy) requires applicable + // annotations — in particular title and destructiveHint. Missing annotations are a + // leading rejection cause. Guards the submission requirement against regressions. + const { tools } = await client.listTools(); + for (const tool of tools) { + expect( + typeof tool.annotations?.title === "string" && tool.annotations.title.length > 0, + `${tool.name} missing title`, + ).toBe(true); + expect(tool.annotations?.destructiveHint, `${tool.name} missing destructiveHint:false`).toBe(false); + } + }); + + it("no tool description leaks the rent-data source brand", async () => { + // Formatters scrub the brand from data output; tool descriptions (sent to the LLM/clients) + // must stay clean too. Guards against a data-source brand leaking into a tool description. + const { tools } = await client.listTools(); + for (const tool of tools) { + const desc = (tool.description ?? "").toLowerCase(); + // A description states what the caller gets back, never which register it came from. + expect(findGuardToken(desc), `${tool.name} description leaks a guarded term`).toBeNull(); + } + }); + + it("surfaces the deep-link permalink recipe in the search tool descriptions", async () => { + // The recipe MUST live in the tool descriptions, not only the server `instructions` field: + // claude.ai's connector doesn't surface `instructions` to the model. + // Guards against the recipe silently dropping back to instructions-only. + const { tools } = await client.listTools(); + for (const name of ["search_transactions", "search_by_area", "search_by_polygon"]) { + const desc = tools.find((t) => t.name === name)?.description ?? ""; + expect(desc, `${name} missing deep-link recipe`).toContain("ceny-transakcyjne?src=mcpstdio#v=1"); + expect(desc, `${name} deep-link recipe missing tx anchor`).toContain("tx="); + } + }); + + it("has all expected tool names (default set, no optional tools)", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); expect(names).toEqual([ "compare_locations", + "estimate_value", + "get_building_breakdown", + "get_demographics", + "get_infrastructure_signals", "get_market_overview", + "get_parcel_report", "get_price_distribution", "get_price_statistics", + "get_transaction_farmland", + "get_transaction_flood", + "get_transaction_heritage", + "get_transaction_landslide", + "get_transaction_permits", + "get_transaction_planning", + "get_transaction_surroundings", + "get_transaction_transit", "list_locations", + "resolve_parcel", "search_by_area", "search_by_polygon", "search_parcels", "search_transactions", ]); }); + + it("does NOT register the optional tools by default", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + for (const gated of ["get_rental_yield", "list_rental_yield_locations", "get_price_spread", "list_price_spread_locations"]) { + expect(names, `${gated} should be gated off`).not.toContain(gated); + } + }); + + it("instructions omit the rental-yield workflow when the tools are gated off (cross-check)", () => { + // Consistency guard: the instructions line and the tool registration read the SAME flag. + // Flag off → neither the workflow line nor the tools should appear. + const instructions = client.getInstructions() ?? ""; + expect(instructions).not.toContain("Rental yield:"); + expect(instructions).not.toContain("Price spread:"); + }); }); // ── Tests: search_transactions ───────────────────────────────────── @@ -282,6 +445,63 @@ describe("search_transactions", () => { expect(text).toContain("Puławska"); expect(text).not.toContain("Error:"); }); + + it("passes valid floor buckets through to api-client as CSV", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_transactions", + arguments: { location: "Mokotów", floor: ["0", "-1", "10plus", "unknown"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ floor: "0,-1,10plus,unknown" }), + "test-api-key", + ); + }); + + it("rejects malformed floor tokens at the schema (no silent full-set)", async () => { + const result = await client.callTool({ + name: "search_transactions", + arguments: { location: "Mokotów", floor: ["abc"] }, + }); + + // Schema validation surfaces an actionable error instead of silently + // dropping the token and returning the full unfiltered set. + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("Invalid floor token"); + expect(mockGetTransactions).not.toHaveBeenCalled(); + }); + + it("rejects a mixed valid+invalid floor array (per-element validation)", async () => { + // The original bug was partial-drop: a bad token silently vanished while + // the good ones filtered. Zod must validate EVERY element, so one garbage + // member rejects the whole array rather than filtering by the rest. + const result = await client.callTool({ + name: "search_transactions", + arguments: { location: "Mokotów", floor: ["1", "abc"] }, + }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("Invalid floor token"); + expect(mockGetTransactions).not.toHaveBeenCalled(); + }); + + it("accepts case-insensitive floor tokens (parity with parser toLowerCase)", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_transactions", + arguments: { location: "Mokotów", floor: ["10Plus", "UNKNOWN"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ floor: "10Plus,UNKNOWN" }), + "test-api-key", + ); + }); }); // ── Tests: get_price_statistics ──────────────────────────────────── @@ -301,6 +521,7 @@ describe("get_price_statistics", () => { it("filters by location (case-insensitive)", async () => { mockGetPricePerM2.mockResolvedValueOnce(withCredits(samplePriceRows)); + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Kraków-Podgórze", "Wola"])); const result = await client.callTool({ name: "get_price_statistics", arguments: { location: "krak" } }); const text = getTextContent(result); @@ -312,6 +533,7 @@ describe("get_price_statistics", () => { it("shows helpful message when no results", async () => { mockGetPricePerM2.mockResolvedValueOnce(withCredits([])); + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Kraków-Podgórze", "Wola"])); const result = await client.callTool({ name: "get_price_statistics", arguments: { location: "Atlantyda" } }); const text = getTextContent(result); @@ -330,10 +552,12 @@ describe("get_price_statistics", () => { { district: "Kraków-Podgórze", avg_price_m2: 12000, median_price_m2: 11500, count: 3000 }, ]; mockGetPricePerM2.mockResolvedValueOnce(withCredits(warsawRows)); + // 'Warszawa' is a city key — resolved from static map, no getDistricts call. const result = await client.callTool({ name: "get_price_statistics", arguments: { location: "Warszawa" } }); const text = getTextContent(result); + expect(mockGetDistricts).not.toHaveBeenCalled(); // Warsaw districts + city-level entry should be present expect(text).toContain("Mokotów"); expect(text).toContain("Wola"); @@ -343,6 +567,34 @@ describe("get_price_statistics", () => { // Non-Warsaw districts should be filtered out expect(text).not.toContain("Kraków-Podgórze"); }); + + // City keys resolve from the static map — no /api/districts fetch. + it("'Warszawa' filters without calling getDistricts (lazy city key)", async () => { + const warsawRows: PricePerM2Row[] = [ + { district: "Mokotów", avg_price_m2: 16000, median_price_m2: 15200, count: 5000 }, + { district: "Wola", avg_price_m2: 14000, median_price_m2: 13500, count: 4000 }, + { district: "Kraków-Podgórze", avg_price_m2: 12000, median_price_m2: 11500, count: 3000 }, + ]; + mockGetPricePerM2.mockResolvedValueOnce(withCredits(warsawRows)); + + const result = await client.callTool({ name: "get_price_statistics", arguments: { location: "Warszawa" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(mockGetPricePerM2).toHaveBeenCalledTimes(1); + expect(text).toContain("Mokotów"); + expect(text).toContain("Wola"); + expect(text).not.toContain("Kraków-Podgórze"); + }); + + it("non-city location still fetches getDistricts (else branch)", async () => { + mockGetPricePerM2.mockResolvedValueOnce(withCredits(samplePriceRows)); + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Kraków-Podgórze", "Wola"])); + + await client.callTool({ name: "get_price_statistics", arguments: { location: "krak" } }); + + expect(mockGetDistricts).toHaveBeenCalledTimes(1); + }); }); // ── Tests: get_price_distribution ────────────────────────────────── @@ -405,6 +657,56 @@ describe("search_by_area", () => { ); }); + it("forwards floodRisk (joined) to backend", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_by_area", + arguments: { latitude: 52.23, longitude: 21.01, radiusKm: 2, floodRisk: ["medium", "high"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ floodRisk: "medium,high" }), + "test-api-key", + ); + }); + + it("forwards heritageStatus (joined) to backend", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_by_area", + arguments: { latitude: 52.23, longitude: 21.01, radiusKm: 2, heritageStatus: ["listed", "zone"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ heritageStatus: "listed,zone" }), + "test-api-key", + ); + }); + + it("forwards landslideRisk (joined) to both rows and summary count", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_by_area", + arguments: { latitude: 52.23, longitude: 21.01, radiusKm: 2, landslideRisk: ["landslide", "threatened"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ landslideRisk: "landslide,threatened" }), + "test-api-key", + ); + // Summary call must carry the same filter so the "Found N" count matches the rows. + expect(mockGetTransactionsSummary).toHaveBeenCalledWith( + expect.objectContaining({ landslideRisk: "landslide,threatened" }), + "test-api-key", + ); + }); + it("returns Error with isError flag on API failure", async () => { mockGetTransactions.mockRejectedValueOnce(new Error("Too many requests.")); mockGetTransactionsSummary.mockRejectedValueOnce(new Error("Too many requests.")); @@ -488,36 +790,35 @@ describe("list_locations", () => { expect(text).toContain("dolnośląskie"); }); - it("filters by search term (legacy mode)", async () => { - mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Śródmieście", "Wola", "Kraków-Podgórze", "Kraków-Śródmieście"])); + it("filters by search term (legacy mode, non-city)", async () => { + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Śródmieście", "Wola"])); - const result = await client.callTool({ name: "list_locations", arguments: { search: "Kraków" } }); + const result = await client.callTool({ name: "list_locations", arguments: { search: "Śród" } }); const text = getTextContent(result); expect(mockGetDistricts).toHaveBeenCalled(); expect(mockGetLocations).not.toHaveBeenCalled(); - expect(text).toContain("Kraków-Podgórze"); - expect(text).toContain("Kraków-Śródmieście"); + expect(text).toContain("Śródmieście"); expect(text).not.toContain("Mokotów"); + expect(text).not.toContain("Wola"); }); - it("search matches diacritics-insensitive ('Krakow' → Kraków districts)", async () => { - mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Kraków-Podgórze", "Kraków-Śródmieście"])); - + // 'Krakow' (no diacritics) resolves to the Kraków city key lazily — no getDistricts. + it("search matches diacritics-insensitive ('Krakow' → Kraków districts, lazy)", async () => { const result = await client.callTool({ name: "list_locations", arguments: { search: "Krakow" } }); const text = getTextContent(result); + expect(mockGetDistricts).not.toHaveBeenCalled(); expect(text).toContain("Kraków-Podgórze"); expect(text).toContain("Kraków-Śródmieście"); expect(text).not.toContain("Mokotów"); }); - it("search matches diacritics-insensitive ('Lodz' → Łódź districts)", async () => { - mockGetDistricts.mockResolvedValueOnce(withCredits(["Łódź-Bałuty", "Łódź-Górna", "Mokotów"])); - + it("search matches diacritics-insensitive ('Lodz' → Łódź districts, lazy)", async () => { const result = await client.callTool({ name: "list_locations", arguments: { search: "Lodz" } }); const text = getTextContent(result); + expect(mockGetDistricts).not.toHaveBeenCalled(); expect(text).toContain("Łódź-Bałuty"); expect(text).toContain("Łódź-Górna"); expect(text).not.toContain("Mokotów"); @@ -533,6 +834,75 @@ describe("list_locations", () => { }); }); +// ── Tests: list_locations lazy city resolution ── +describe("list_locations lazy city key", () => { + it("'Warszawa' returns all 19 sub-districts without calling getDistricts", async () => { + const result = await client.callTool({ name: "list_locations", arguments: { search: "Warszawa" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(text).toContain("Found 19 locations"); + expect(text).toContain("Mokotów"); + expect(text).toContain("Żoliborz"); + expect(text).toContain("Praga-Południe"); + // No credit footer for city-key matches (zero API call = zero cost) + expect(text).not.toContain("API tokens"); + }); + + it("trailing whitespace 'Warszawa ' still resolves lazily", async () => { + const result = await client.callTool({ name: "list_locations", arguments: { search: "Warszawa " } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(text).toContain("Found 19 locations"); + }); + + it("lowercase 'warszawa' still resolves lazily", async () => { + const result = await client.callTool({ name: "list_locations", arguments: { search: "warszawa" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(text).toContain("Found 19 locations"); + }); + + it("'Kraków' returns 5 sub-districts without getDistricts", async () => { + const result = await client.callTool({ name: "list_locations", arguments: { search: "Kraków" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(text).toContain("Found 5 locations"); + expect(text).toContain("Kraków-Podgórze"); + }); + + it("'Łódź' returns 6 sub-districts without getDistricts", async () => { + const result = await client.callTool({ name: "list_locations", arguments: { search: "Łódź" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).not.toHaveBeenCalled(); + expect(text).toContain("Found 6 locations"); + expect(text).toContain("Łódź-Bałuty"); + }); + + it("partial 'Mok' is NOT a city key — falls through to getDistricts", async () => { + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Wola", "Kraków-Podgórze"])); + + const result = await client.callTool({ name: "list_locations", arguments: { search: "Mok" } }); + const text = getTextContent(result); + + expect(mockGetDistricts).toHaveBeenCalledTimes(1); + expect(text).toContain("Mokotów"); + expect(text).not.toContain("Wola"); + }); + + it("multi-word 'Warszawa Mokotów' is NOT a city key — falls through to getDistricts", async () => { + mockGetDistricts.mockResolvedValueOnce(withCredits(["Mokotów", "Wola"])); + + await client.callTool({ name: "list_locations", arguments: { search: "Warszawa Mokotów" } }); + + expect(mockGetDistricts).toHaveBeenCalledTimes(1); + }); +}); + // ── Tests: search_parcels ────────────────────────────────────────── describe("search_parcels", () => { @@ -556,135 +926,1317 @@ describe("search_parcels", () => { }); }); -// ── Tests: search_by_polygon ─────────────────────────────────────── +// ── Tests: resolve_parcel ────────────────────────────────────────── -describe("search_by_polygon", () => { - const polygon = { - type: "Polygon" as const, - coordinates: [[[21.0, 52.2], [21.01, 52.2], [21.01, 52.21], [21.0, 52.21], [21.0, 52.2]]], - }; +describe("resolve_parcel", () => { + it("passes q to the API and formats matches", async () => { + mockResolveParcel.mockResolvedValueOnce(withCredits(sampleParcelResolve)); - it("passes polygon and maps propertyType to number", async () => { - mockSearchByPolygon.mockResolvedValueOnce(withCredits(sampleSpatialResponse)); + const result = await client.callTool({ name: "resolve_parcel", arguments: { q: "Wawer 27" } }); + const text = getTextContent(result); - await client.callTool({ - name: "search_by_polygon", - arguments: { polygon, propertyType: "unit" }, - }); + expect(mockResolveParcel).toHaveBeenCalledWith( + { q: "Wawer 27", parcelId: undefined, lat: undefined, lng: undefined }, + "test-api-key", + ); + expect(text).toContain("146518_8.0108.27"); + expect(text).toContain("Wawer"); + expect(text).toContain("52.1234"); + expect(text).toContain("API tokens: 48 remaining"); + }); - expect(mockSearchByPolygon).toHaveBeenCalledWith( - expect.objectContaining({ - polygon, - propertyType: 4, - }), + it("passes parcelId to the API", async () => { + mockResolveParcel.mockResolvedValueOnce(withCredits(sampleParcelResolve)); + + await client.callTool({ name: "resolve_parcel", arguments: { parcelId: "146518_8.0108.27" } }); + + expect(mockResolveParcel).toHaveBeenCalledWith( + { q: undefined, parcelId: "146518_8.0108.27", lat: undefined, lng: undefined }, "test-api-key", ); }); - it("passes optional filters", async () => { - mockSearchByPolygon.mockResolvedValueOnce(withCredits(sampleSpatialResponse)); + it("passes lat/lng together to the API", async () => { + mockResolveParcel.mockResolvedValueOnce(withCredits(sampleParcelResolve)); - await client.callTool({ - name: "search_by_polygon", - arguments: { polygon, minPrice: 300000, dateFrom: "2024-01-01", limit: 50 }, - }); + await client.callTool({ name: "resolve_parcel", arguments: { lat: 52.12, lng: 21.05 } }); - expect(mockSearchByPolygon).toHaveBeenCalledWith( - expect.objectContaining({ - minPrice: 300000, - dateFrom: "2024-01-01", - limit: 50, - }), + expect(mockResolveParcel).toHaveBeenCalledWith( + { q: undefined, parcelId: undefined, lat: 52.12, lng: 21.05 }, "test-api-key", ); }); - it("shows truncation warning when response is truncated", async () => { - const truncated: SpatialSearchResponse = { - ...sampleSpatialResponse, - truncated: true, - total: 5000, - }; - mockSearchByPolygon.mockResolvedValueOnce(withCredits(truncated)); + it("rejects zero modes without calling the API", async () => { + const result = await client.callTool({ name: "resolve_parcel", arguments: {} }); + expect(getTextContent(result)).toContain("exactly one lookup mode"); + expect(mockResolveParcel).not.toHaveBeenCalled(); + }); + it("rejects two modes without calling the API", async () => { const result = await client.callTool({ - name: "search_by_polygon", - arguments: { polygon }, + name: "resolve_parcel", + arguments: { q: "Wawer 27", parcelId: "146518_8.0108.27" }, }); - const text = getTextContent(result); + expect(getTextContent(result)).toContain("exactly one lookup mode"); + expect(mockResolveParcel).not.toHaveBeenCalled(); + }); - expect(text).toContain("truncated"); - expect(text).toContain("5"); + it("rejects a lone lat without lng", async () => { + const result = await client.callTool({ name: "resolve_parcel", arguments: { lat: 52.12 } }); + expect(getTextContent(result)).toContain("lat and lng must be provided together"); + expect(mockResolveParcel).not.toHaveBeenCalled(); + }); + + it("surfaces not_covered as a refunded miss", async () => { + mockResolveParcel.mockResolvedValueOnce(withCredits({ + query: { mode: "q", q: "Nieznane 999" }, + coverage: "not_covered", + as_of: null, + matches: [], + truncated: false, + } satisfies ParcelResolveResponse)); + + const result = await client.callTool({ name: "resolve_parcel", arguments: { q: "Nieznane 999" } }); + const text = getTextContent(result); + expect(text).toContain("No parcel matched"); + expect(text).toContain("refunded"); }); }); -// ── Tests: compare_locations ─────────────────────────────────────── +// ── Tests: estimate_value ────────────────────────────────────────── + +const sampleValuation: ValuationResponse = { + location: { country_code: "PL", lat: 52.2297, lng: 21.0122, county_code: "1465" }, + metric: "estimated_apartment_value", + currency: "PLN", + segment: { property_type: "apartment", market_type: "all", area_m2: 55, rooms: null }, + result: { + estimated_value: 1210000, + price_per_m2: 22000, + value_range_likely: { low: 1100000, high: 1320000 }, + value_range_wide: { low: 950000, high: 1450000 }, + confidence: 0.82, + confidence_band: "high", + }, + inputs: { + comps_total: 34, + radius_m: 1000, + window_months: 24, + comparables: [ + { distance_m: 210, transaction_date: "2025-08-12", area_m2: 54, price_per_m2: 21500, rooms: 3, floor: 4, market_type: "secondary", district: "Mokotów", has_unit_number: true, unit_number: "11_LOK" }, + ], + }, + quality: { + coverage: "covered", + as_of: "2025-11-23", + price_basis: "apartment", + ess: 18.4, + accuracy_segment: "wwa", + note: "Indicative market-value estimate for an apartment. This is an orientation estimate, NOT a certified appraisal (operat szacunkowy).", + }, +}; -describe("compare_locations", () => { - it("passes districts string and maps filters", async () => { - mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); +describe("estimate_value", () => { + it("passes lat/lng + area and formats the estimate (comps included by default)", async () => { + mockGetValuation.mockResolvedValueOnce(withCredits(sampleValuation)); - await client.callTool({ - name: "compare_locations", - arguments: { districts: "Mokotów,Wola", propertyType: "unit", dateFrom: "2024-01-01" }, + const result = await client.callTool({ name: "estimate_value", arguments: { lat: 52.2297, lng: 21.0122, area: 55 } }); + const text = getTextContent(result); + + expect(mockGetValuation).toHaveBeenCalledWith( + { lat: 52.2297, lng: 21.0122, parcelId: undefined, area: 55, rooms: undefined, market: undefined, includeComps: true }, + "test-api-key", + ); + expect(text).toContain("Apartment value estimate"); + expect(text).toContain("Confidence: high"); + expect(text).toContain("Mokotów"); + expect(text).toContain("operat szacunkowy"); + expect(text).toContain("API tokens: 48 remaining"); + }); + + it("passes parcelId + optional rooms/market", async () => { + mockGetValuation.mockResolvedValueOnce(withCredits(sampleValuation)); + + await client.callTool({ name: "estimate_value", arguments: { parcelId: "146502_8.0403.10", area: 62, rooms: 3, market: "secondary" } }); + + expect(mockGetValuation).toHaveBeenCalledWith( + { lat: undefined, lng: undefined, parcelId: "146502_8.0403.10", area: 62, rooms: 3, market: "secondary", includeComps: true }, + "test-api-key", + ); + }); + + it("forwards includeComps=false", async () => { + mockGetValuation.mockResolvedValueOnce(withCredits(sampleValuation)); + + await client.callTool({ name: "estimate_value", arguments: { lat: 52.2, lng: 21.0, area: 40, includeComps: false } }); + + expect(mockGetValuation).toHaveBeenCalledWith( + expect.objectContaining({ includeComps: false }), + "test-api-key", + ); + }); + + it("rejects zero location modes without calling the API", async () => { + const result = await client.callTool({ name: "estimate_value", arguments: { area: 55 } }); + expect(getTextContent(result)).toContain("exactly one location"); + expect(mockGetValuation).not.toHaveBeenCalled(); + }); + + it("rejects both lat/lng and parcelId without calling the API", async () => { + const result = await client.callTool({ + name: "estimate_value", + arguments: { lat: 52.2, lng: 21.0, parcelId: "146502_8.0403.10", area: 55 }, + }); + expect(getTextContent(result)).toContain("exactly one location"); + expect(mockGetValuation).not.toHaveBeenCalled(); + }); + + it("rejects a lone lat without lng", async () => { + const result = await client.callTool({ name: "estimate_value", arguments: { lat: 52.2, area: 55 } }); + expect(getTextContent(result)).toContain("lat and lng must be provided together"); + expect(mockGetValuation).not.toHaveBeenCalled(); + }); + + it("rejects area below 10 at the schema (no API call)", async () => { + const result = await client.callTool({ name: "estimate_value", arguments: { lat: 52.2, lng: 21.0, area: 5 } }); + expect(result.isError).toBe(true); + expect(mockGetValuation).not.toHaveBeenCalled(); + }); + + // Latitude bounds mirror search_by_area — only points inside Poland are meaningful. + it("rejects a pin outside Poland at the schema (no API call)", async () => { + for (const args of [ + { lat: 90, lng: 0 }, + { lat: -90, lng: 180 }, + { lat: 89.99, lng: 21 }, + { lat: 48.5, lng: 21 }, + { lat: 52.2, lng: 30 }, + ]) { + const result = await client.callTool({ name: "estimate_value", arguments: { ...args, area: 55 } }); + expect(result.isError).toBe(true); + } + expect(mockGetValuation).not.toHaveBeenCalled(); + }); + + it("surfaces no_data as a refunded miss (no estimate)", async () => { + mockGetValuation.mockResolvedValueOnce(withCredits({ + ...sampleValuation, + location: { country_code: "PL", lat: 52.9, lng: 19.1, county_code: null }, + result: { + estimated_value: null, price_per_m2: null, + value_range_likely: { low: null, high: null }, value_range_wide: { low: null, high: null }, + confidence: null, confidence_band: null, + }, + inputs: { comps_total: 2, radius_m: null, window_months: 24, comparables: null }, + quality: { ...sampleValuation.quality, coverage: "no_data", as_of: null, accuracy_segment: null, ess: null }, + } satisfies ValuationResponse)); + + const result = await client.callTool({ name: "estimate_value", arguments: { lat: 52.9, lng: 19.1, area: 55 } }); + const text = getTextContent(result); + expect(text).toContain("No estimate"); + expect(text).toContain("refunded"); + }); +}); + +// ── Tests: search_by_polygon ─────────────────────────────────────── + +describe("search_by_polygon", () => { + const polygon = { + type: "Polygon" as const, + coordinates: [[[21.0, 52.2], [21.01, 52.2], [21.01, 52.21], [21.0, 52.21], [21.0, 52.2]]], + }; + + it("passes polygon and maps propertyType to number", async () => { + mockSearchByPolygon.mockResolvedValueOnce(withCredits(sampleSpatialResponse)); + + await client.callTool({ + name: "search_by_polygon", + arguments: { polygon, propertyType: "unit" }, + }); + + expect(mockSearchByPolygon).toHaveBeenCalledWith( + expect.objectContaining({ + polygon, + propertyType: 4, + }), + "test-api-key", + ); + }); + + it("passes optional filters", async () => { + mockSearchByPolygon.mockResolvedValueOnce(withCredits(sampleSpatialResponse)); + + await client.callTool({ + name: "search_by_polygon", + arguments: { polygon, minPrice: 300000, dateFrom: "2024-01-01", limit: 50 }, + }); + + expect(mockSearchByPolygon).toHaveBeenCalledWith( + expect.objectContaining({ + minPrice: 300000, + dateFrom: "2024-01-01", + limit: 50, + }), + "test-api-key", + ); + }); + + it("shows truncation warning when response is truncated", async () => { + const truncated: SpatialSearchResponse = { + ...sampleSpatialResponse, + truncated: true, + total: 5000, + }; + mockSearchByPolygon.mockResolvedValueOnce(withCredits(truncated)); + + const result = await client.callTool({ + name: "search_by_polygon", + arguments: { polygon }, + }); + const text = getTextContent(result); + + expect(text).toContain("truncated"); + expect(text).toContain("5"); + }); +}); + +// ── Tests: optional tools (CENOGRAM_EXPERIMENTAL_TOOLS=1) ── +// These 4 tools are gated off by default; the default suite above asserts their absence. +// Here we flip the flag and build a SEPARATE server/client so the nested describes +// register + exercise them. `client` is shadowed → the describes below bind lexically to +// this flag-on client, not the module-level (flag-off) one. Env is restored in afterAll. +describe("optional tools (flag on)", () => { + let client: Client; + let prevFlag: string | undefined; + + beforeAll(async () => { + prevFlag = process.env.CENOGRAM_EXPERIMENTAL_TOOLS; + process.env.CENOGRAM_EXPERIMENTAL_TOOLS = "1"; + const { createMcpServer } = await import("../index.js"); + const server = createMcpServer("test-api-key"); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + client = new Client({ name: "test-client-exp", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterAll(() => { + if (prevFlag === undefined) delete process.env.CENOGRAM_EXPERIMENTAL_TOOLS; + else process.env.CENOGRAM_EXPERIMENTAL_TOOLS = prevFlag; + }); + + it("registers all 27 tools including the 4 optional tools", async () => { + const { tools } = await client.listTools(); + expect(tools).toHaveLength(27); + const names = tools.map((t) => t.name); + expect(names).toEqual(expect.arrayContaining([ + "get_rental_yield", + "list_rental_yield_locations", + "get_price_spread", + "list_price_spread_locations", + ])); + }); + + it("instructions surface the rental-yield workflow when the tools are registered (cross-check)", () => { + // Same flag drives both the instructions line and the registration — flag on → both present. + const instructions = client.getInstructions() ?? ""; + expect(instructions).toContain("Rental yield:"); + expect(instructions).toContain("Price spread:"); + }); + +describe("get_rental_yield", () => { + const sampleYield = { + location: { name: "Warszawa", country_code: "PL", location_type: "city", teryt: "1465" }, + metric: "indicative_gross_rental_yield", + currency: "PLN", + segment: { market_type: "secondary", property_type: "apartment", area_bucket: null }, + result: { gross_yield_pct: 5.5, calculation_method: "ratio_of_market_medians", matched_observations: false }, + inputs: { + rent: { median_monthly_asking_per_m2: 75.49, annualized_per_m2: 905.88, sample_n: 7468, snapshot_date: "2025-11-20" }, + transaction: { median_price_per_m2: 16472, sample_n: 13263, window: { from: "2024-11-20", to: "2025-11-23" } }, + }, + distribution: { + asking_rent_monthly_per_m2: { p10: 50, p25: 62, p50: 75.49, p75: 90, p90: 110 }, + transaction_price_per_m2: { p10: 12000, p25: 14000, p50: 16472, p75: 19000, p90: 22000 }, + }, + assumptions: { vacancy_included: false, tax_included: false, maintenance_included: false, transaction_costs_included: false }, + quality: { coverage: "full", confidence: "high", as_of: "2025-11-23", stale: false, notes: ["indicative gross yield, excludes vacancy and tax"] }, + }; + + it("returns a friendly message when neither location nor teryt is given", async () => { + const result = await client.callTool({ name: "get_rental_yield", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("location"); + expect(text).toContain("teryt"); + expect(mockGetRentalYield).not.toHaveBeenCalled(); + }); + + it("formats a yield result and passes location through", async () => { + mockGetRentalYield.mockResolvedValueOnce(withCredits(sampleYield)); + const result = await client.callTool({ name: "get_rental_yield", arguments: { location: "Warszawa" } }); + const text = getTextContent(result); + expect(text).toContain("Warszawa"); + expect(text).toContain("5.5%"); + expect(text).toContain("secondary market"); + expect(mockGetRentalYield).toHaveBeenCalledWith({ location: "Warszawa", teryt: undefined, areaBucket: undefined }, expect.any(String)); + }); + + it("no_rental_data coverage → tip points at list_rental_yield_locations, REST-URL note hidden", async () => { + const noData = { + ...sampleYield, + result: { ...sampleYield.result, gross_yield_pct: null }, + inputs: { rent: { ...sampleYield.inputs.rent, sample_n: null }, transaction: sampleYield.inputs.transaction }, + quality: { ...sampleYield.quality, coverage: "no_rental_data", notes: ["Brak danych czynszowych dla tej lokalizacji — listę pokrytych miast zwraca GET /api/v1/rental-yield/locations"] }, + }; + mockGetRentalYield.mockResolvedValueOnce(withCredits(noData)); + const result = await client.callTool({ name: "get_rental_yield", arguments: { teryt: "0215" } }); + const text = getTextContent(result); + expect(text).toContain("list_rental_yield_locations"); + // REST path note suppressed in MCP output (LLM gets a tool name, not a URL) + expect(text).not.toContain("/api/v1/rental-yield/locations"); + }); +}); + +describe("list_rental_yield_locations", () => { + const sampleCatalog = { + data: [ + { location: "Warszawa", county_code: "1465", voivodeship: "Mazowieckie", type: "city", rent_sample_n: 7040, confidence: "high" }, + { location: "legionowski", county_code: "1408", voivodeship: "Mazowieckie", type: "county", rent_sample_n: 120, confidence: "high" }, + ], + meta: { total: 2, snapshot_date: "2026-06-02" }, + }; + + it("formats the catalog with header, snapshot date, and per-location lines", async () => { + mockGetRentalYieldLocations.mockResolvedValueOnce(withCredits(sampleCatalog)); + const result = await client.callTool({ name: "list_rental_yield_locations", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("2 locations"); + expect(text).toContain("2026-06-02"); + expect(text).toContain("Warszawa (teryt 1465, Mazowieckie, city)"); + expect(text).toContain("legionowski (teryt 1408, Mazowieckie, county)"); + expect(mockGetRentalYieldLocations).toHaveBeenCalledWith({ search: undefined }, expect.any(String)); + }); + + it("passes search through to the API", async () => { + mockGetRentalYieldLocations.mockResolvedValueOnce(withCredits({ data: [sampleCatalog.data[0]], meta: { total: 1, snapshot_date: "2026-06-02" } })); + const result = await client.callTool({ name: "list_rental_yield_locations", arguments: { search: "warsz" } }); + const text = getTextContent(result); + expect(text).toContain("Warszawa"); + expect(mockGetRentalYieldLocations).toHaveBeenCalledWith({ search: "warsz" }, expect.any(String)); + }); + + it("empty catalog → friendly no-match message", async () => { + mockGetRentalYieldLocations.mockResolvedValueOnce(withCredits({ data: [], meta: { total: 0, snapshot_date: null } })); + const result = await client.callTool({ name: "list_rental_yield_locations", arguments: { search: "zzz" } }); + const text = getTextContent(result); + expect(text).toContain("No rental-yield-covered locations match"); + }); +}); + +describe("get_price_spread", () => { + const sampleSpread = { + location: { name: "Warszawa", country_code: "PL", location_type: "city", teryt: "1465" }, + metric: "asking_to_transaction_price_spread", + currency: "PLN", + segment: { market_type: "all", property_type: "apartment", area_bucket: null }, + result: { spread_pct: 8.41, calculation_method: "relative_difference_of_market_medians", matched_observations: false }, + inputs: { + asking: { median_price_per_m2: 16585, sample_n: 200, snapshot_date: "2026-06-01" }, + transaction: { median_price_per_m2: 15298, sample_n: 500, window: { from: "2025-06-01", to: "2026-06-01" } }, + }, + distribution: { + asking_sale_per_m2: { p10: 12000, p25: 14500, p50: 16585, p75: 19000, p90: 23000 }, + transaction_price_per_m2: { p10: 11000, p25: 13200, p50: 15298, p75: 18000, p90: 21000 }, + }, + quality: { coverage: "full", confidence: "high", as_of: "2026-06-01", stale: false, notes: ["spread = how far the median asking price exceeds the median transaction price (may be negative)"] }, + }; + + it("returns a friendly message when neither location nor teryt is given", async () => { + const result = await client.callTool({ name: "get_price_spread", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("location"); + expect(text).toContain("teryt"); + expect(mockGetPriceSpread).not.toHaveBeenCalled(); + }); + + it("formats a spread result and passes location + marketType through", async () => { + mockGetPriceSpread.mockResolvedValueOnce(withCredits(sampleSpread)); + const result = await client.callTool({ name: "get_price_spread", arguments: { location: "Warszawa", marketType: "all" } }); + const text = getTextContent(result); + expect(text).toContain("Warszawa"); + expect(text).toContain("+8.41%"); + expect(text).toContain("all market"); + expect(mockGetPriceSpread).toHaveBeenCalledWith({ location: "Warszawa", teryt: undefined, marketType: "all", areaBucket: undefined }, expect.any(String)); + }); + + it("negative spread rendered as 'below transaction'", async () => { + mockGetPriceSpread.mockResolvedValueOnce(withCredits({ ...sampleSpread, result: { ...sampleSpread.result, spread_pct: -6.67 } })); + const result = await client.callTool({ name: "get_price_spread", arguments: { location: "Warszawa" } }); + const text = getTextContent(result); + expect(text).toContain("-6.67%"); + expect(text).toContain("below transaction"); + }); + + it("no_asking_data coverage → tip points at list_price_spread_locations, REST-URL note hidden", async () => { + const noData = { + ...sampleSpread, + result: { ...sampleSpread.result, spread_pct: null }, + inputs: { asking: { ...sampleSpread.inputs.asking, sample_n: null }, transaction: sampleSpread.inputs.transaction }, + quality: { ...sampleSpread.quality, coverage: "no_asking_data", notes: ["Brak danych ofertowych sprzedaży dla tej lokalizacji — listę pokrytych miast zwraca GET /api/v1/price-spread/locations"] }, + }; + mockGetPriceSpread.mockResolvedValueOnce(withCredits(noData)); + const result = await client.callTool({ name: "get_price_spread", arguments: { teryt: "0215" } }); + const text = getTextContent(result); + expect(text).toContain("list_price_spread_locations"); + // REST path note suppressed in MCP output (LLM gets a tool name, not a URL) + expect(text).not.toContain("/api/v1/price-spread/locations"); + }); +}); + +describe("list_price_spread_locations", () => { + const sampleCatalog = { + data: [ + { location: "Warszawa", county_code: "1465", voivodeship: "Mazowieckie", type: "city", asking_sample_n: 5000, confidence: "high" }, + { location: "Kraków", county_code: "1261", voivodeship: "Małopolskie", type: "city", asking_sample_n: 900, confidence: "high" }, + ], + meta: { total: 2, snapshot_date: "2026-06-02" }, + }; + + it("formats the catalog with header, snapshot date, and per-location lines", async () => { + mockGetPriceSpreadLocations.mockResolvedValueOnce(withCredits(sampleCatalog)); + const result = await client.callTool({ name: "list_price_spread_locations", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("2 locations"); + expect(text).toContain("2026-06-02"); + expect(text).toContain("Warszawa (teryt 1465, Mazowieckie, city)"); + expect(text).toContain("Kraków (teryt 1261, Małopolskie, city)"); + expect(mockGetPriceSpreadLocations).toHaveBeenCalledWith({ search: undefined }, expect.any(String)); + }); + + it("passes search through to the API", async () => { + mockGetPriceSpreadLocations.mockResolvedValueOnce(withCredits({ data: [sampleCatalog.data[0]], meta: { total: 1, snapshot_date: "2026-06-02" } })); + const result = await client.callTool({ name: "list_price_spread_locations", arguments: { search: "warsz" } }); + const text = getTextContent(result); + expect(text).toContain("Warszawa"); + expect(mockGetPriceSpreadLocations).toHaveBeenCalledWith({ search: "warsz" }, expect.any(String)); + }); + + it("empty catalog → friendly no-match message", async () => { + mockGetPriceSpreadLocations.mockResolvedValueOnce(withCredits({ data: [], meta: { total: 0, snapshot_date: null } })); + const result = await client.callTool({ name: "list_price_spread_locations", arguments: { search: "zzz" } }); + const text = getTextContent(result); + expect(text).toContain("No price-spread-covered locations match"); + }); +}); + +}); // end optional tools (flag on) + +describe("compare_locations", () => { + it("passes districts string and maps filters", async () => { + mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + + await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "unit", dateFrom: "2024-01-01" }, + }); + + expect(mockCompareLocations).toHaveBeenCalledWith( + expect.objectContaining({ + districts: "Mokotów,Wola", + propertyType: 4, + dateFrom: "2024-01-01", + }), + "test-api-key", + ); + }); + + it("forwards mpzpDesignation to backend", async () => { + mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + + await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "land", mpzpDesignation: "terenRolniczy" }, + }); + + expect(mockCompareLocations).toHaveBeenCalledWith( + expect.objectContaining({ + districts: "Mokotów,Wola", + propertyType: 1, + mpzpDesignation: "terenRolniczy", + }), + "test-api-key", + ); + }); + + it("renders comparison table", async () => { + mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "unit" }, + }); + const text = getTextContent(result); + + expect(text).toContain("Location comparison"); + expect(text).toContain("Mokotów"); + expect(text).toContain("Wola"); + expect(text).toContain("2024-01-01"); + }); + + it("shows suggestions for unmatched districts", async () => { + const withSuggestion: CompareResponse = { + "Mokotow": { median_price_m2: null, avg_area: null, min_date: null, max_date: null, total: 0, suggestions: ["Mokotów"] }, + }; + mockCompareLocations.mockResolvedValueOnce(withCredits(withSuggestion)); + + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotow,Wola", propertyType: "unit" }, + }); + const text = getTextContent(result); + + expect(text).toContain("Did you mean: Mokotów"); + }); + + it("rejects 6 districts client-side (zod refinement)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "A,B,C,D,E,F", propertyType: "unit" }, + }); + expect(getTextContent(result)).toContain("2-5 unique"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects 1 district client-side (zod refinement)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów", propertyType: "unit" }, + }); + expect(getTextContent(result)).toContain("2-5 unique"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects duplicate-collapsed list below 2 unique (zod refinement)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Mokotów,Mokotów", propertyType: "unit" }, + }); + expect(getTextContent(result)).toContain("2-5 unique"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects empty filter list (handler-side)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola" }, + }); + expect(getTextContent(result)).toContain("at least one filter"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects trailing comma collapsing to 1 unique (zod refinement)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,", propertyType: "unit" }, + }); + expect(getTextContent(result)).toContain("2-5 unique"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects empty districts string (zod refinement)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "", propertyType: "unit" }, + }); + expect(getTextContent(result)).toContain("2-5 unique"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects empty-string street as sole filter (handler-side)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", street: "" }, + }); + expect(getTextContent(result)).toContain("at least one filter"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("rejects whitespace-only string filters (mirror of server's safeString)", async () => { + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", street: " ", dateFrom: " " }, + }); + expect(getTextContent(result)).toContain("at least one filter"); + expect(mockCompareLocations).not.toHaveBeenCalled(); + }); + + it("includeDemographics=true sends include=demographics to the backend", async () => { + mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "unit", includeDemographics: true }, }); + expect(mockCompareLocations).toHaveBeenCalledWith( + expect.objectContaining({ include: "demographics" }), + "test-api-key", + ); + }); + + it("omits include when includeDemographics is not set", async () => { + mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "unit" }, + }); + expect(mockCompareLocations).toHaveBeenCalledWith( + expect.objectContaining({ include: undefined }), + "test-api-key", + ); + }); + + it("renders the demographics block for districts that carry it, tolerating absence", async () => { + const withDemo: CompareResponse = { + "Mokotów": { + ...sampleCompareResponse["Mokotów"]!, + demographics: { + unemployment_rate: { value: 3.2, year: 2024, unit: "%" }, + price_to_income_years: { value: 14.5, year: null, unit: "years", derived: true, cross_source: true }, + }, + }, + // Wola has no demographics key (REST omits it for unresolved districts) → must not crash. + "Wola": { ...sampleCompareResponse["Wola"]! }, + }; + mockCompareLocations.mockResolvedValueOnce(withCredits(withDemo)); + const result = await client.callTool({ + name: "compare_locations", + arguments: { districts: "Mokotów,Wola", propertyType: "unit", includeDemographics: true }, + }); + const text = getTextContent(result); + expect(text).toContain("Demographics (GUS BDL"); + expect(text).toContain("unemployment_rate"); + expect(text).toContain("cross-source"); + expect(text).toContain("no demographic data for Wola"); + }); +}); + +// ── Tests: get_demographics ──────────────────────────────────────── + +describe("get_demographics", () => { + const sampleDemographics = { + location: { + name: "Warszawa", + country_code: "PL" as const, + location_type: "city" as const, + teryt: "1465", + level: "powiat", + hierarchy: { wojewodztwo: { teryt: "14", name: "Mazowieckie" } }, + }, + coverage: "full" as const, + indicators: { + population_density: { name: "Gęstość zaludnienia", unit: "osoba/km²", variable_id: 60559, category: "demographics", level: "powiat", values: { "2024": 3500 } }, + unemployment_rate: { name: "Stopa bezrobocia", unit: "%", variable_id: 60270, category: "economy", level: "powiat", values: { "2024": 3.2 } }, + higher_education_pct: { name: "% z wyższym wykształceniem", unit: "%", variable_id: null, category: "education", level: "powiat", values: { "2021": 45.6 }, derived: true, snapshot: true }, + }, + meta: { variables_count: 3, categories: ["demographics", "economy", "education"], levels_included: ["powiat", "wojewodztwo"], data_source: "GUS BDL (Bank Danych Lokalnych)", as_of: "2024" }, + }; + + it("returns a friendly message when neither location nor teryt is given", async () => { + const result = await client.callTool({ name: "get_demographics", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("location"); + expect(text).toContain("teryt"); + expect(mockGetDemographics).not.toHaveBeenCalled(); + }); + + it("rejects a malformed teryt before the API call", async () => { + const result = await client.callTool({ name: "get_demographics", arguments: { teryt: "146" } }); + expect(getTextContent(result)).toContain("Invalid teryt"); + expect(mockGetDemographics).not.toHaveBeenCalled(); + }); + + it("formats grouped indicators and passes location through", async () => { + mockGetDemographics.mockResolvedValueOnce(withCredits(sampleDemographics)); + const result = await client.callTool({ name: "get_demographics", arguments: { location: "Warszawa" } }); + const text = getTextContent(result); + expect(text).toContain("Warszawa"); + expect(text).toContain("Demographics"); + expect(text).toContain("Economy"); + expect(text).toContain("Gęstość zaludnienia"); + expect(text).toContain("as of 2024"); + expect(text).toContain("[derived, snapshot]"); + expect(mockGetDemographics).toHaveBeenCalledWith( + expect.objectContaining({ location: "Warszawa", teryt: undefined }), + "test-api-key", + ); + }); + + it("joins a category array into a CSV param", async () => { + mockGetDemographics.mockResolvedValueOnce(withCredits(sampleDemographics)); + await client.callTool({ name: "get_demographics", arguments: { teryt: "14", category: ["housing", "economy"] } }); + expect(mockGetDemographics).toHaveBeenCalledWith( + expect.objectContaining({ teryt: "14", category: "housing,economy" }), + "test-api-key", + ); + }); + + it("renders a readable message for coverage:no_data", async () => { + mockGetDemographics.mockResolvedValueOnce(withCredits({ + location: { name: null, country_code: "PL", location_type: "county", teryt: "9999", level: "powiat", hierarchy: {} }, + coverage: "no_data", + indicators: {}, + meta: { variables_count: 0, categories: [], levels_included: [], data_source: "GUS BDL (Bank Danych Lokalnych)", as_of: null }, + })); + const result = await client.callTool({ name: "get_demographics", arguments: { teryt: "9999" } }); + const text = getTextContent(result); + expect(text).toContain("No GUS BDL indicators are available"); + expect(text).toContain("9999"); + }); +}); + +// ── Tests: get_infrastructure_signals ────────────────────────────── + +describe("get_infrastructure_signals", () => { + const sampleSignals = { + location: { name: "Warszawa", country_code: "PL" as const, location_type: "municipality" as const, teryt: "146501", level: "gmina" }, + coverage: "full" as const, + tenders: { + window_months: 12, + by_category: { roads: 6, sewerage: 2 }, + recent: [ + { title: "Budowa kanalizacji", category: "sewerage", notice_type: "ContractNotice", published_at: "2026-07-07", value_pln: 1234567.89, value_kind: "estimated", attribution_confidence: "high", bzp_url: "https://example.test/1" }, + { title: "Przebudowa drogi powiatowej", category: "roads", notice_type: "ContractNotice", published_at: "2026-07-01", value_pln: null, value_kind: null, attribution_confidence: "low", bzp_url: null }, + ], + truncated: false, + }, + kposk: { in_agglomeration: true, agglomerations: [{ name: "Warszawa", rlm: 2500000 }], truncated: false }, + capex: { by_year: { "2026": { value_pln: 4506814136, doc_category: "ZmianaWPF", resolution_date: "2026-03-12", gmina_count: 1 } } }, + meta: { coverage_note: "Tenders come from the national public procurement bulletin, which carries below-EU-threshold contracts only (from 2021).", as_of: "2026-07-07" }, + }; + + it("returns a friendly message when neither location nor teryt is given", async () => { + const result = await client.callTool({ name: "get_infrastructure_signals", arguments: {} }); + const text = getTextContent(result); + expect(text).toContain("location"); + expect(text).toContain("teryt"); + expect(mockGetInfrastructureSignals).not.toHaveBeenCalled(); + }); + + it("rejects a malformed teryt before the API call (no credit spent)", async () => { + const result = await client.callTool({ name: "get_infrastructure_signals", arguments: { teryt: "146" } }); + expect(getTextContent(result)).toContain("Invalid teryt"); + expect(mockGetInfrastructureSignals).not.toHaveBeenCalled(); + }); + + it("formats the three overlays and flags non-municipal attribution", async () => { + mockGetInfrastructureSignals.mockResolvedValueOnce(withCredits(sampleSignals)); + const result = await client.callTool({ name: "get_infrastructure_signals", arguments: { teryt: "146501" } }); + const text = getTextContent(result); + expect(text).toContain("Infrastructure signals — Warszawa"); + expect(text).toContain("Roads: 6"); + expect(text).toContain("estimated value"); + // The caveat belongs on the low-confidence notice ONLY. Assert per line: a substring check on + // the whole text would pass even if every notice carried it. + const lines = text.split("\n"); + const municipal = lines.find((l) => l.includes("Budowa kanalizacji")) ?? ""; + const county = lines.find((l) => l.includes("Przebudowa drogi powiatowej")) ?? ""; + expect(municipal).not.toContain("authority based here"); + expect(county).toContain("authority based here, works may be elsewhere"); + expect(text).toContain("In a designated agglomeration"); + expect(text).toContain("Planned capital expenditure"); + expect(text).toContain("below-EU-threshold"); + expect(mockGetInfrastructureSignals).toHaveBeenCalledWith( + expect.objectContaining({ teryt: "146501", location: undefined }), + "test-api-key", + ); + }); + + it("county aggregation says so and reports how many municipalities were summed", async () => { + mockGetInfrastructureSignals.mockResolvedValueOnce(withCredits({ + ...sampleSignals, + location: { name: "krotoszyński", country_code: "PL", location_type: "county", teryt: "3004", level: "powiat" }, + capex: { by_year: { "2026": { value_pln: 1000, doc_category: "mixed", resolution_date: "2026-03-12", gmina_count: 7 } } }, + })); + const text = getTextContent(await client.callTool({ name: "get_infrastructure_signals", arguments: { location: "krotoszyński" } })); + expect(text).toContain("aggregated over every municipality in this county"); + expect(text).toContain("summed across 7 municipalities"); + }); + + // Dual-state: the tool must never let the model conclude "this gmina is not investing". + it("coverage:no_data renders the dual-state disclaimer, not an affirmative negative", async () => { + mockGetInfrastructureSignals.mockResolvedValueOnce(withCredits({ + location: { name: "Testowo", country_code: "PL", location_type: "municipality", teryt: "026402", level: "gmina" }, + coverage: "no_data", + tenders: { window_months: 12, by_category: {}, recent: [], truncated: false }, + kposk: { in_agglomeration: false, agglomerations: [], truncated: false }, + capex: { by_year: {} }, + meta: { coverage_note: "Tenders come from the national public procurement bulletin.", as_of: null }, + })); + const text = getTextContent(await client.callTool({ name: "get_infrastructure_signals", arguments: { teryt: "026402" } })); + expect(text).toContain("No infrastructure signals are recorded"); + expect(text).toContain("does NOT mean the municipality is not investing"); + }); +}); + +// ── Tests: get_building_breakdown ────────────────────────────────── + +describe("get_building_breakdown", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a multi-building breakdown with credit footer", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ + data: [ + { building_type: 110, footprint_area_m2: "250.00", footprint_area_alt_m2: null, footprint_divergent: null, storeys: 3, est_total_area_m2: "750.00", match_confidence: "high" }, + { building_type: 127, footprint_area_m2: "40.00", footprint_area_alt_m2: null, footprint_divergent: null, storeys: 1, est_total_area_m2: "40.00", match_confidence: "low" }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetBuildingBreakdown).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-building breakdown (2 buildings)"); + expect(text).toContain("Residential (Mieszkalny)"); + expect(text).toContain("Farm/Utility (Gospodarczy)"); + expect(text).toContain("storeys 3"); + expect(text).toContain("est. total floor area"); + expect(text).toContain("match confidence: high"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("surfaces the alternate footprint only when the two measurements diverge", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ + data: [ + { building_type: 110, footprint_area_m2: "250.00", footprint_area_alt_m2: "280.00", footprint_divergent: true, storeys: 2, est_total_area_m2: "500.00", match_confidence: "high" }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("alt. measurement"); + expect(text).toContain("diverge"); + expect(text).toMatch(/280/); + }); + + it("single building without a second measurement renders no alt/confidence noise", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ + data: [ + { building_type: 110, footprint_area_m2: "120.00", footprint_area_alt_m2: null, footprint_divergent: null, storeys: 1, est_total_area_m2: "120.00", match_confidence: null }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); - expect(mockCompareLocations).toHaveBeenCalledWith( - expect.objectContaining({ - districts: "Mokotów,Wola", - propertyType: 4, - dateFrom: "2024-01-01", - }), - "test-api-key", - ); + expect(text).toContain("footprint"); + expect(text).not.toContain("alt. measurement"); + expect(text).not.toContain("match confidence"); + expect(text).not.toContain("null"); }); - it("forwards mpzpDesignation to backend", async () => { - mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + it("empty data (no buildings / unknown id) → friendly message", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); - await client.callTool({ - name: "compare_locations", - arguments: { districts: "Mokotów,Wola", propertyType: "land", mpzpDesignation: "terenRolniczy" }, - }); + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); - expect(mockCompareLocations).toHaveBeenCalledWith( - expect.objectContaining({ - districts: "Mokotów,Wola", - propertyType: 1, - mpzpDesignation: "terenRolniczy", - }), - "test-api-key", - ); + expect(text).toContain("No per-building data available"); }); - it("renders comparison table", async () => { - mockCompareLocations.mockResolvedValueOnce(withCredits(sampleCompareResponse)); + it("truncated response → shows the 500-building note", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ + data: [{ building_type: 110, footprint_area_m2: "100.00", footprint_area_alt_m2: null, footprint_divergent: null, storeys: 1, est_total_area_m2: "100.00", match_confidence: "high" }], + truncated: true, + })); - const result = await client.callTool({ - name: "compare_locations", - arguments: { districts: "Mokotów,Wola" }, - }); + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); const text = getTextContent(result); - expect(text).toContain("Location comparison"); - expect(text).toContain("Mokotów"); - expect(text).toContain("Wola"); - expect(text).toContain("2024-01-01"); + expect(text).toContain("first 500 buildings"); }); - it("shows suggestions for unmatched districts", async () => { - const withSuggestion: CompareResponse = { - "Mokotow": { median_price_m2: null, avg_area: null, min_date: null, max_date: null, total: 0, suggestions: ["Mokotów"] }, - }; - mockCompareLocations.mockResolvedValueOnce(withCredits(withSuggestion)); + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: "not-a-uuid" } }); - const result = await client.callTool({ - name: "compare_locations", - arguments: { districts: "Mokotow" }, - }); + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetBuildingBreakdown).not.toHaveBeenCalled(); + }); + + it("never names the source register", async () => { + mockGetBuildingBreakdown.mockResolvedValueOnce(withCredits({ + data: [{ building_type: 110, footprint_area_m2: "250.00", footprint_area_alt_m2: "280.00", footprint_divergent: true, storeys: 3, est_total_area_m2: "750.00", match_confidence: "high" }], + truncated: false, + })); + + const result = await client.callTool({ name: "get_building_breakdown", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result).toLowerCase(); + + expect(findGuardToken(text)).toBeNull(); + }); +}); + +// ── Tests: get_transaction_flood ─────────────────────────────────── + +describe("get_transaction_flood", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-parcel flood breakdown with credit footer", async () => { + mockGetTransactionFlood.mockResolvedValueOnce(withCredits({ + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN labels, no source-register fingerprint). + { + flood_risk: "high", severity_rank: 1, worst_scenario: "river flood, 1-in-10-year", source: "river", + depth_class: null, pct_in_zone: "45.00", nearest_zone_m: 0, + scenarios: [{ scenario: "river flood, 1-in-10-year", source: "river", returnPeriod: 10, severity: 1, isLeveeFailure: false, depthClass: null }], + }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_flood", arguments: { transaction_id: VALID_UUID } }); const text = getTextContent(result); - expect(text).toContain("Did you mean: Mokotów"); + expect(mockGetTransactionFlood).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel flood-zone breakdown (1 parcel in"); + expect(text).toContain("risk: high (~1-in-10-year)"); + expect(text).toContain("source: river"); + expect(findGuardToken(text)).toBeNull(); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message that never asserts safety", async () => { + mockGetTransactionFlood.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); + + const result = await client.callTool({ name: "get_transaction_flood", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No mapped flood-hazard zone"); + expect(text).toContain("not a guarantee of safety"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_flood", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionFlood).not.toHaveBeenCalled(); + }); +}); + +// ── Tests: get_transaction_heritage ──────────────────────────────── + +describe("get_transaction_heritage", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-parcel heritage breakdown with credit footer", async () => { + mockGetTransactionHeritage.mockResolvedValueOnce(withCredits({ + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN categories, no source-register fingerprint). + { + heritage_status: "listed", severity_rank: 1, pct_in_zone: "45.00", site_count: 1, + sites: [{ category: "building", name: "Townhouse", function: "residential", period: "19th c.", entry_date: "1967-05-12" }], + }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_heritage", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetTransactionHeritage).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel heritage-listing breakdown (1 parcel with"); + expect(text).toContain("status: listed (protected monument on/at the parcel)"); + expect(text).toContain("Townhouse"); + expect(text).toContain("Indicative data"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message that never asserts the absence of protection", async () => { + mockGetTransactionHeritage.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); + + const result = await client.callTool({ name: "get_transaction_heritage", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No heritage-listing records found"); + expect(text).toContain("not a statement that the property is free of heritage protection"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_heritage", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionHeritage).not.toHaveBeenCalled(); + }); +}); + +// ── Tests: get_transaction_landslide ─────────────────────────────── + +describe("get_transaction_landslide", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-parcel landslide breakdown with credit footer", async () => { + mockGetTransactionLandslide.mockResolvedValueOnce(withCredits({ + data: [ + // Input mirrors the API's already-scrubbed shape (neutral EN kinds, no source-register fingerprint). + { + landslide_risk: "landslide", severity_rank: 1, pct_in_zone: "45.00", + zones: [{ kind: "landslide", source_version_date: "2021-03-15" }], + }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_landslide", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetTransactionLandslide).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel landslide-zone breakdown (1 parcel intersecting"); + expect(text).toContain("risk: landslide (a mapped landslide area)"); + expect(text).toContain("record version date: 2021-03-15"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message that never asserts safety", async () => { + mockGetTransactionLandslide.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); + + const result = await client.callTool({ name: "get_transaction_landslide", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No mapped landslide-hazard zone"); + expect(text).toContain("not a guarantee of safety"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_landslide", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionLandslide).not.toHaveBeenCalled(); + }); +}); + +// ── Tests: get_transaction_surroundings ──────────────────────────── + +describe("get_transaction_surroundings", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-plot surroundings breakdown with credit footer", async () => { + mockGetTransactionSurroundings.mockResolvedValueOnce(withCredits({ + data: [ + { + assessed: true, + cemetery_distance_m: 240.5, + landfill_distance_m: null, + sewage_treatment_distance_m: null, + industrial_area_distance_m: "890.00", + industrial_plant_distance_m: "1500.00", + livestock_farm_distance_m: null, + }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_surroundings", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetTransactionSurroundings).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel surroundings (1 plot;"); + expect(text).toContain("cemetery: ~241 m"); + expect(text).toContain("landfill (waste disposal): none within 3 km"); + expect(text).toContain("industrial/storage area: ~890 m"); + expect(text).toContain("large industrial plant: ~1500 m"); + expect(text).toContain("intensive livestock farm: none within 3 km"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message (no linked plots or unknown id)", async () => { + mockGetTransactionSurroundings.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); + + const result = await client.callTool({ name: "get_transaction_surroundings", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No surroundings data is available"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_surroundings", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionSurroundings).not.toHaveBeenCalled(); + }); +}); + +// ── Tests: get_transaction_transit ────────────────────────────────── + +describe("get_transaction_transit", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-parcel transit breakdown with credit footer", async () => { + mockGetTransactionTransit.mockResolvedValueOnce(withCredits({ + data: [ + { + rail_distance_m: 850, rail_stop_name: "Central Station", + metro_distance_m: null, metro_stop_name: null, + tram_distance_m: 300, tram_stop_name: "Market Square", + bus_distance_m: 120, bus_stop_name: "Post Office", + }, + ], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_transit", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetTransactionTransit).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel public transport access (1 parcel"); + expect(text).toContain("Rail: 850 m (Central Station)"); + expect(text).toContain("Tram: 300 m (Market Square)"); + expect(text).toContain("Bus: 120 m (Post Office)"); + expect(text).not.toContain("Metro:"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message that never asserts 'no transit access'", async () => { + mockGetTransactionTransit.mockResolvedValueOnce(withCredits({ data: [], truncated: false })); + + const result = await client.callTool({ name: "get_transaction_transit", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No public transport stop is recorded"); + expect(text).not.toContain("no transit access"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_transit", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionTransit).not.toHaveBeenCalled(); + }); + + it("never names a feed aggregator, carrier, or transit authority", async () => { + mockGetTransactionTransit.mockResolvedValueOnce(withCredits({ + data: [{ + rail_distance_m: 500, rail_stop_name: "Central Station", + metro_distance_m: null, metro_stop_name: null, + tram_distance_m: null, tram_stop_name: null, + bus_distance_m: null, bus_stop_name: null, + }], + truncated: false, + })); + + const result = await client.callTool({ name: "get_transaction_transit", arguments: { transaction_id: VALID_UUID } }); + expect(findGuardToken(getTextContent(result))).toBeNull(); + }); +}); + +// ── Tests: get_transaction_farmland ──────────────────────────────── + +describe("get_transaction_farmland", () => { + const VALID_UUID = "11111111-2222-3333-4444-555555555555"; + + it("renders a per-parcel agricultural land-eligibility breakdown with credit footer", async () => { + mockGetTransactionFarmland.mockResolvedValueOnce(withCredits({ + data: [{ eligible_area_m2: 3984, pct_of_parcel: 87, feature_count: 2 }], + truncated: false, + parcels_total: 1, + parcels_with_data: 1, + as_of: "2026-07-01", + })); + + const result = await client.callTool({ name: "get_transaction_farmland", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(mockGetTransactionFarmland).toHaveBeenCalledWith(VALID_UUID, "test-api-key"); + expect(text).toContain("Per-parcel agricultural land-eligibility (1 of 1 linked parcel with a matched eligible area)"); + expect(text).toContain("eligible agricultural area: 3984 m²"); + expect(text).toContain("87% of the parcel"); + expect(text).toContain("snapshot as of 2026-07-01"); + expect(text).toMatch(/API tokens.*48/); + }); + + it("two-state empty data → neutral message that never asserts the land is non-agricultural", async () => { + mockGetTransactionFarmland.mockResolvedValueOnce(withCredits({ + data: [], truncated: false, parcels_total: 0, parcels_with_data: 0, as_of: null, + })); + + const result = await client.callTool({ name: "get_transaction_farmland", arguments: { transaction_id: VALID_UUID } }); + const text = getTextContent(result); + + expect(text).toContain("No eligible agricultural area found for the linked parcels"); + expect(text).toContain("not a statement that the property is non-agricultural"); + }); + + it("rejects a malformed transaction_id via zod, no API call (protects credit)", async () => { + const result = await client.callTool({ name: "get_transaction_farmland", arguments: { transaction_id: "not-a-uuid" } }); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("UUID"); + expect(mockGetTransactionFarmland).not.toHaveBeenCalled(); + }); +}); + +// ── Tests: search_transactions building id surfacing + CTA ───────── + +describe("search_transactions building surfacing", () => { + const txWithBuildings = { + ...sampleTransaction, + id: "11111111-2222-3333-4444-555555555555", + building_count: 2, + footprint_area_m2: "300.00", + est_total_area_m2: "600.00", + }; + + it("surfaces the transaction id + breakdown tip when a row has buildings", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits({ + data: [txWithBuildings], + pagination: { page: 1, limit: 10, total: 1, pages: 1 }, + })); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + const result = await client.callTool({ name: "search_transactions", arguments: {} }); + const text = getTextContent(result); + + expect(text).toContain("id: 11111111-2222-3333-4444-555555555555"); + expect(text).toContain("get_building_breakdown"); + }); + + it("surfaces id but NOT the breakdown tip when no row has buildings", async () => { + // id is unconditional (deep link); the get_building_breakdown tip stays gated. + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + const result = await client.callTool({ name: "search_transactions", arguments: {} }); + const text = getTextContent(result); + + expect(text).toContain("id: tx-1"); + expect(text).not.toContain("get_building_breakdown"); + }); +}); + +// ── Tests: search_by_polygon vertex count validation ────────────── + +describe("search_by_polygon validation", () => { + it("rejects polygon with >500 vertices (zod refinement)", async () => { + // Build a single ring with 502 vertices (501 unique + closing point) + const ring: [number, number][] = []; + for (let i = 0; i < 501; i++) { + ring.push([21.0 + i * 0.0001, 52.2]); + } + ring.push(ring[0]!); // close ring + const result = await client.callTool({ + name: "search_by_polygon", + arguments: { polygon: { type: "Polygon", coordinates: [ring] } }, + }); + expect(getTextContent(result)).toContain("500 total vertices"); + expect(mockSearchByPolygon).not.toHaveBeenCalled(); }); }); @@ -966,6 +2518,72 @@ describe("edge cases", () => { }), "test-api-key", ); + // Summary call must carry the same filters so the "Found N" count matches the rows. + expect(mockGetTransactionsSummary).toHaveBeenCalledWith( + expect.objectContaining({ + street: "Puławska", + buildingNumber: "15A", + parcelId: "146509_8.0501.12", + }), + "test-api-key", + ); + }); + + it("search_transactions forwards floodRisk to both rows and summary count", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_transactions", + arguments: { location: "Warszawa", floodRisk: ["high"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ floodRisk: "high" }), + "test-api-key", + ); + expect(mockGetTransactionsSummary).toHaveBeenCalledWith( + expect.objectContaining({ floodRisk: "high" }), + "test-api-key", + ); + }); + + it("search_transactions forwards heritageStatus to both rows and summary count", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_transactions", + arguments: { location: "Warszawa", heritageStatus: ["listed"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ heritageStatus: "listed" }), + "test-api-key", + ); + expect(mockGetTransactionsSummary).toHaveBeenCalledWith( + expect.objectContaining({ heritageStatus: "listed" }), + "test-api-key", + ); + }); + + it("search_transactions forwards landslideRisk to both rows and summary count", async () => { + mockGetTransactions.mockResolvedValueOnce(withCredits(sampleTransactionsResponse)); + mockGetTransactionsSummary.mockResolvedValueOnce(withCredits(sampleSummary)); + + await client.callTool({ + name: "search_transactions", + arguments: { location: "Warszawa", landslideRisk: ["landslide", "threatened"] }, + }); + + expect(mockGetTransactions).toHaveBeenCalledWith( + expect.objectContaining({ landslideRisk: "landslide,threatened" }), + "test-api-key", + ); + expect(mockGetTransactionsSummary).toHaveBeenCalledWith( + expect.objectContaining({ landslideRisk: "landslide,threatened" }), + "test-api-key", + ); }); it("search_transactions defaults: sort=date, order=desc, limit=10", async () => { @@ -980,3 +2598,135 @@ describe("edge cases", () => { ); }); }); + +// ── Tests: tool.call identity logging (user_id) + Sentry per-call user ── +// Verifies which user made a call is now observable: OAuth -> UUID in the stderr `tool.call` log AND +// Sentry scope.setUser; api-key -> key_prefix in both. Guards against the old flatten-to-"oauth" bug. +describe("tool.call identity: user_id in stderr log + Sentry setUser", () => { + const OAUTH_UUID = "3f9a1c22-1b7e-4d0a-9c11-2b3c4d5e6f70"; + + // Build a fresh server bound to `apiKey`, run one tool call, return the parsed `tool.call` log line. + async function runOneCall(apiKey: string, toolName: string): Promise> { + const writes: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }); + try { + const { createMcpServer } = await import("../index.js"); + const server = createMcpServer(apiKey); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const c = new Client({ name: "id-test", version: "1.0.0" }); + await c.connect(clientTransport); + await c.callTool({ name: toolName, arguments: {} }); + await c.close(); + } finally { + spy.mockRestore(); + } + const line = writes.reverse().find((w) => w.includes('"tool.call"')); + if (!line) throw new Error("no tool.call log line captured"); + return JSON.parse(line) as Record; + } + + it("OAuth call: user_id = decoded UUID, key_prefix = 'oauth', Sentry user = UUID", async () => { + mockGetStats.mockResolvedValueOnce(withCredits(sampleStats)); + const log = await runOneCall(encodeOAuthCtx(OAUTH_UUID, "grant-x"), "get_market_overview"); + + expect(log.evt).toBe("tool.call"); + expect(log.user_id).toBe(OAUTH_UUID); + expect(log.key_prefix).toBe("oauth"); + expect(log.success).toBe(true); + expect(mockSentrySetUser).toHaveBeenCalledWith({ id: OAUTH_UUID }); + }); + + it("OAuth error path: captureException fires and Sentry user is still the UUID", async () => { + mockGetStats.mockRejectedValueOnce(new Error("upstream 500")); + const log = await runOneCall(encodeOAuthCtx(OAUTH_UUID, "grant-x"), "get_market_overview"); + + expect(log.user_id).toBe(OAUTH_UUID); + expect(log.success).toBe(false); + expect(mockSentrySetUser).toHaveBeenCalledWith({ id: OAUTH_UUID }); + expect(mockSentryCaptureException).toHaveBeenCalledTimes(1); + }); + + it("api-key call: user_id = key_prefix (cngrm_xxxx), Sentry user = same prefix", async () => { + mockGetStats.mockResolvedValueOnce(withCredits(sampleStats)); + const log = await runOneCall("cngrm_test_abcd1234", "get_market_overview"); + + expect(log.user_id).toBe("cngrm_test"); + expect(log.key_prefix).toBe("cngrm_test"); + expect(mockSentrySetUser).toHaveBeenCalledWith({ id: "cngrm_test" }); + }); + + // End-to-end guard for the plan-CR MAJOR: a malformed OAuth-shaped key (\x01 prefix, no valid ctx) + // must NOT leak the raw \x01 control byte into the log — it stays the stable "oauth" label. + it("malformed OAuth key: falls back to 'oauth', no raw \\x01 byte in the log line", async () => { + mockGetStats.mockResolvedValueOnce(withCredits(sampleStats)); + const writes: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }); + try { + const { createMcpServer } = await import("../index.js"); + const server = createMcpServer("\x01onlyUserIdNoSeparator"); // no second \x01 -> decode returns null + const [ct, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + const c = new Client({ name: "id-test", version: "1.0.0" }); + await c.connect(ct); + await c.callTool({ name: "get_market_overview", arguments: {} }); + await c.close(); + } finally { + spy.mockRestore(); + } + const rawLine = writes.reverse().find((w) => w.includes('"tool.call"'))!; + expect(rawLine).not.toContain("\x01"); + const log = JSON.parse(rawLine) as Record; + expect(log.key_prefix).toBe("oauth"); + expect(log.user_id).toBe("oauth"); + expect(mockSentrySetUser).toHaveBeenCalledWith({ id: "oauth" }); + }); +}); + +// A tool call without an auth context used to answer "Internal: missing auth context … report +// bug". On stdio that is simply someone who has not got a key yet, and the message sent them to +// an issue tracker instead of to the page that hands keys out. +describe("missing auth context", () => { + const origTransport = process.env.MCP_TRANSPORT; + + afterEach(() => { + if (origTransport === undefined) delete process.env.MCP_TRANSPORT; + else process.env.MCP_TRANSPORT = origTransport; + }); + + async function callWithoutKey(): Promise { + const { createMcpServer } = await import("../index.js"); + const server = createMcpServer(undefined as unknown as string); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const c = new Client({ name: "no-key-test", version: "1.0.0" }); + await c.connect(clientTransport); + const result = await c.callTool({ name: "get_market_overview", arguments: {} }); + await c.close(); + return getTextContent(result); + } + + it("stdio: points at the signup page and the config key, not at the bug tracker", async () => { + delete process.env.MCP_TRANSPORT; + const text = await callWithoutKey(); + expect(text).toContain("https://cenogram.pl/api?src=mcpstdio"); + expect(text).toContain("CENOGRAM_API_KEY"); + expect(text).not.toMatch(/internal/i); + expect(text).not.toMatch(/report/i); + expect(text).not.toContain("/ustawienia"); + }); + + it("hosted HTTP: still a bug, and says so", async () => { + process.env.MCP_TRANSPORT = "http"; + const text = await callWithoutKey(); + expect(text).toMatch(/bug on our side/i); + expect(text).toContain("github.com/cenogram/mcp-server/issues"); + expect(text).not.toContain("CENOGRAM_API_KEY"); + }); +}); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index d539d5e..d0a47d5 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -2,11 +2,17 @@ import { describe, it, expect } from "vitest"; import { mapPropertyType, mapMarketType, + mapUnitFunction, + mapBuildingType, + mapOwnershipTypes, + mapTransactionTypes, radiusKmToBbox, filterByLocation, PROPERTY_TYPES, MARKET_TYPES, + resolveDistrict, } from "../mappings.js"; +import { encodeOAuthCtx, decodeOAuthCtx, OAUTH_CTX_PREFIX } from "../api-client.js"; describe("mapPropertyType", () => { it("maps 'unit' to 4", () => { @@ -118,7 +124,7 @@ describe("filterByLocation", () => { expect(filterByLocation("GDAŃSK", districts)).toEqual(["Gdańsk"]); }); - it("'Warszawa' does NOT match Warsaw districts", () => { + it("'Warszawa' does not match Warsaw districts (filterByLocation is substring only)", () => { const result = filterByLocation("Warszawa", districts); expect(result).toEqual([]); }); @@ -132,6 +138,41 @@ describe("filterByLocation", () => { }); }); +describe("resolveDistrict", () => { + const allDistricts = [ + "Warszawa", "Bemowo", "Białołęka", "Bielany", "Mokotów", "Ochota", + "Praga-Południe", "Praga-Północ", "Rembertów", "Śródmieście", "Targówek", + "Ursus", "Ursynów", "Wawer", "Wesoła", "Wilanów", "Włochy", "Wola", "Żoliborz", + "Kraków", "Kraków-Krowodrza", "Kraków-Nowa Huta", "Kraków-Podgórze", "Kraków-Śródmieście", + "Łódź", "Łódź-Bałuty", "Łódź-Górna", "Łódź-Polesie", "Łódź-Śródmieście", "Łódź-Widzew", + "Gdańsk", "Wrocław", + ]; + + it("resolves 'Krakow' (no diacritics) to 5 sub-districts", () => { + expect(resolveDistrict("Krakow", allDistricts)).toHaveLength(5); + }); + + it("resolves 'warszawa' (lowercase) to 19 sub-districts", () => { + expect(resolveDistrict("warszawa", allDistricts)).toHaveLength(19); + }); + + it("resolves 'Lodz' to 6 sub-districts", () => { + expect(resolveDistrict("Lodz", allDistricts)).toHaveLength(6); + }); + + it("resolves 'mokotow' to ['Mokotów']", () => { + expect(resolveDistrict("mokotow", allDistricts)).toEqual(["Mokotów"]); + }); + + it("resolves 'gdansk' to ['Gdańsk']", () => { + expect(resolveDistrict("gdansk", allDistricts)).toEqual(["Gdańsk"]); + }); + + it("passes through unknown district", () => { + expect(resolveDistrict("xyz", allDistricts)).toEqual(["xyz"]); + }); +}); + describe("enum constants", () => { it("PROPERTY_TYPES has all 4 types", () => { expect(PROPERTY_TYPES[1]).toContain("Land"); @@ -145,3 +186,102 @@ describe("enum constants", () => { expect(MARKET_TYPES[2]).toContain("Secondary"); }); }); + +describe("mapUnitFunction", () => { + it("maps named functions to codes", () => { + expect(mapUnitFunction("residential")).toBe(1); + expect(mapUnitFunction("garage")).toBe(5); + }); + + it("maps 'unknown' to the 'unknown' sentinel string (resolved to IS NULL by the API)", () => { + expect(mapUnitFunction("unknown")).toBe("unknown"); + }); + + it("returns undefined for undefined / unmapped value", () => { + expect(mapUnitFunction(undefined)).toBeUndefined(); + expect(mapUnitFunction("bogus")).toBeUndefined(); + }); +}); + +describe("mapBuildingType", () => { + it("maps named PKOB types to codes", () => { + expect(mapBuildingType("residential")).toBe(110); + expect(mapBuildingType("office")).toBe(124); + }); + + it("maps 'unknown' to the 'unknown' sentinel string", () => { + expect(mapBuildingType("unknown")).toBe("unknown"); + }); + + it("returns undefined for undefined / unmapped value", () => { + expect(mapBuildingType(undefined)).toBeUndefined(); + expect(mapBuildingType("bogus")).toBeUndefined(); + }); +}); + +describe("mapTransactionTypes", () => { + it("joins mapped codes into a CSV string", () => { + expect(mapTransactionTypes(["free_market", "auction"])).toBe("1,3"); + }); + + it("passes the 'unknown' sentinel through alongside codes", () => { + expect(mapTransactionTypes(["free_market", "unknown"])).toBe("1,unknown"); + expect(mapTransactionTypes(["unknown"])).toBe("unknown"); + }); + + it("returns undefined for empty / undefined", () => { + expect(mapTransactionTypes(undefined)).toBeUndefined(); + expect(mapTransactionTypes([])).toBeUndefined(); + }); +}); + +describe("decodeOAuthCtx (identity decode for tool.call logging)", () => { + const UUID = "3f9a1c22-1b7e-4d0a-9c11-2b3c4d5e6f70"; + + it("round-trips a well-formed OAuth context key", () => { + const key = encodeOAuthCtx(UUID, "grant-abc"); + expect(decodeOAuthCtx(key)).toEqual({ userId: UUID, grantId: "grant-abc" }); + }); + + it("returns null for a plain api-key (no \\x01 prefix)", () => { + expect(decodeOAuthCtx("cngrm_live_abcd1234")).toBeNull(); + }); + + it("returns null when the separator is missing (only userId, no grantId sep)", () => { + expect(decodeOAuthCtx(`${OAUTH_CTX_PREFIX}${UUID}`)).toBeNull(); + }); + + it("returns null for an empty userId (\\x01\\x01grant)", () => { + expect(decodeOAuthCtx(`${OAUTH_CTX_PREFIX}${OAUTH_CTX_PREFIX}grant`)).toBeNull(); + }); + + it("returns null for an empty grantId (\\x01user\\x01)", () => { + expect(decodeOAuthCtx(`${OAUTH_CTX_PREFIX}${UUID}${OAUTH_CTX_PREFIX}`)).toBeNull(); + }); +}); + +describe("mapOwnershipTypes", () => { + it("maps named legal-right types to registry codes", () => { + expect(mapOwnershipTypes(["land_ownership"])).toBe("1"); + expect(mapOwnershipTypes(["ownership"])).toBe("5"); + }); + + it("expands perpetual_usufruct to both registry codes 2 and 8", () => { + // the registry records użytkowanie wieczyste under code 2 or 8 depending on the record — cover both. + expect(mapOwnershipTypes(["perpetual_usufruct"])).toBe("2,8"); + }); + + it("joins multi-select into one CSV (land + usufruct)", () => { + expect(mapOwnershipTypes(["land_ownership", "perpetual_usufruct"])).toBe("1,2,8"); + }); + + it("passes the 'unknown' sentinel through alongside codes", () => { + expect(mapOwnershipTypes(["ownership", "unknown"])).toBe("5,unknown"); + expect(mapOwnershipTypes(["unknown"])).toBe("unknown"); + }); + + it("returns undefined for empty / undefined", () => { + expect(mapOwnershipTypes(undefined)).toBeUndefined(); + expect(mapOwnershipTypes([])).toBeUndefined(); + }); +}); diff --git a/src/api-client.ts b/src/api-client.ts index a9b0aed..708b557 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -40,14 +40,75 @@ export interface Transaction { floor: number | null; district: string | null; street: string | null; + // Street provenance: 'rcn' = from the deed/registry; 'approx_high'/'approx_low' = + // approximated by us (not from the deed); 'none' = no street. Surfaced so callers and the LLM + // never treat an approximated street as registry-sourced. + address_source?: "rcn" | "approx_high" | "approx_low" | "none" | null; building_number: string | null; city: string | null; parcel_area: number | null; unit_function: number | null; + // Garage provenance. is_garage = the row is a garage/parking space (opt-in via + // unitFunction=garage). area_basis: 'unit' = usable_area_m2 is a plausible single spot area + // (price/m² computed); 'building' = it's the whole garage-building footprint (price/m² omitted). + is_garage?: boolean | null; + area_basis?: "unit" | "building" | null; parcel_id: string | null; parcel_number: string | null; county_name: string | null; voivodeship_name: string | null; + // Share basis: 'fraction' = price reflects a fractional ownership share, + // excluded from market median. Surfaced per-row so users see which comparables are fractional. + share_basis?: "full" | "fraction" | "ambiguous" | null; + // ── Provenance signals — drive the "Z RCN / Obliczone" markers. ── + // parcel_count >= 2 → parcel_area is a SUM of plots (computed). area_is_ha_converted → parcel_area + // was converted from hectares the county reports in ha (computed). property_type_inferred / + // property_type_reclassed → property_type is derived/corrected by us, not the registry's literal code. + parcel_count?: number | null; + area_is_ha_converted?: boolean | null; + property_type_inferred?: boolean | null; + property_type_reclassed?: boolean | null; + // ── Raw deed fields — straight from the notarial deed (RCN), no marker. ── + // ownership_type / seller_type / buyer_type = enum codes (mappings.ts dicts). ownership_share = the + // raw share text ("1/2"). land_use = RCN land-use code. unit_price = deed price of the unit alone + // (PLN, NOT per-m²) — NUMERIC over the wire → string. vat = raw VAT field (may be a rate % OR an + // amount, as recorded — no unit) — NUMERIC over the wire → string. + ownership_type?: number | null; + ownership_share?: string | null; + seller_type?: number | null; + buyer_type?: number | null; + land_use?: string | null; + unit_price?: number | string | null; + vat?: number | string | null; + // Building attrs — per-transaction aggregates. Neutral names (no source register). + // Gate on building_count first (NULL = no buildings, not 0). SUM fields are NULL unless every + // linked building is complete. NUMERIC arrives as string over the wire (Intl.format coerces). + building_count?: number | null; + footprint_area_m2?: number | null; + est_total_area_m2?: number | null; + building_storeys?: number | null; // single-building transactions only + // Flood-hazard (per-transaction worst-case over the linked parcels). TWO-STATE: flood_risk is + // 'high'|'medium'|'low' ONLY when a linked parcel sits in a mapped flood-hazard zone; absence is null and + // is NEVER surfaced as "safe". flood_assessed = ≥1 linked parcel had geometry (plumbing for the aggregate, + // NOT "assessed safe") — never surfaced affirmatively. get_transaction_flood gives the per-parcel detail. + flood_risk?: "high" | "medium" | "low" | null; + flood_assessed?: boolean | null; + // Heritage listing (per-transaction worst-case over the linked parcels). TWO-STATE: heritage_status + // is 'listed' (a protected monument on/at a linked parcel) or 'zone' (within a protected urban layout + // or designated monument surroundings) ONLY when a listing was detected; absence is null and is NEVER + // surfaced as "not listed". The status is a floor ("at least") — a listed property may also sit inside + // a protected zone. heritage_assessed = ≥1 linked parcel had geometry (plumbing for the aggregate, NOT + // "assessed clear") — never surfaced affirmatively. get_transaction_heritage gives the per-parcel detail. + heritage_status?: "listed" | "zone" | null; + heritage_assessed?: boolean | null; + // Landslide-hazard (per-transaction worst-case over the linked parcels), from official + // landslide-hazard maps (1:10,000 scale). TWO-STATE: landslide_risk is 'landslide'|'threatened' ONLY + // when a linked parcel intersects a mapped hazard area; absence is null and is NEVER surfaced as + // "safe". An intersection means the parcel overlaps a mapped area, not that the parcel itself is a + // landslide. landslide_assessed = ≥1 linked parcel had geometry (plumbing for the aggregate, NOT + // "assessed safe") — never surfaced affirmatively. get_transaction_landslide gives per-parcel detail. + landslide_risk?: "landslide" | "threatened" | null; + landslide_assessed?: boolean | null; centroid: { type: string; coordinates: [number, number] } | null; } @@ -78,6 +139,254 @@ export interface HistogramBin { range_max: number; } +// Component price-per-m2 percentile ladder (shared by both endpoints' distribution blocks). +export interface Percentiles { + p10: number | null; + p25: number | null; + p50: number | null; + p75: number | null; + p90: number | null; +} + +// Resolved location envelope. teryt = 4-digit county code. +export interface LocationRef { + name: string; + country_code: "PL"; + location_type: "city" | "county"; + teryt: string; +} + +// Trailing transaction window (full ISO dates; each bound independently nullable). +export interface TxWindow { + from: string | null; + to: string | null; +} + +// Nested response envelope. Shallow grouping: location / metric / currency / segment / result / +// inputs / distribution / assumptions / quality. Units explicit (top-level currency + _per_m2), +// dates full ISO, floats rounded. +export interface RentalYieldResponse { + location: LocationRef; + metric: "indicative_gross_rental_yield"; + currency: "PLN"; + segment: { + market_type: "primary" | "secondary" | "all"; + property_type: "apartment"; + area_bucket: string | null; // m² range; null = whole stock + }; + result: { + gross_yield_pct: number | null; + calculation_method: "ratio_of_market_medians"; + matched_observations: false; + }; + inputs: { + rent: { + median_monthly_asking_per_m2: number | null; + annualized_per_m2: number | null; + sample_n: number | null; + snapshot_date: string | null; // active-offer snapshot (a point in time, NOT a window) + }; + transaction: { + median_price_per_m2: number | null; + sample_n: number | null; + window: TxWindow; + }; + }; + distribution: { + asking_rent_monthly_per_m2: Percentiles; + transaction_price_per_m2: Percentiles; + }; + assumptions: { + vacancy_included: false; + tax_included: false; + maintenance_included: false; + transaction_costs_included: false; + }; + quality: { + coverage: "full" | "low_sample" | "no_rental_data" | "suppressed" | "data_stale"; + confidence: "high" | "low"; + as_of: string | null; + stale: boolean; + notes: string[]; + }; +} + +export interface RentalYieldLocation { + location: string; + county_code: string; + voivodeship: string; + type: "city" | "county"; + rent_sample_n: number; + confidence: "high" | "low"; +} + +export interface RentalYieldLocationsResponse { + data: RentalYieldLocation[]; + meta: { total: number; snapshot_date: string | null }; +} + +// ── Price spread ──────────────────────────────────────── + +// Mirrors RentalYieldResponse. Differences: metric, result.spread_pct (MAY be negative), +// inputs.asking (sale, not /month), NO assumptions block (the time-basis caveat lives in quality.notes). +export interface PriceSpreadResponse { + location: LocationRef; + metric: "asking_to_transaction_price_spread"; + currency: "PLN"; + segment: { + market_type: "primary" | "secondary" | "all"; + property_type: "apartment"; + area_bucket: string | null; + }; + result: { + spread_pct: number | null; + calculation_method: "relative_difference_of_market_medians"; + matched_observations: false; + }; + inputs: { + asking: { + median_price_per_m2: number | null; + sample_n: number | null; + snapshot_date: string | null; + window?: TxWindow; + }; + transaction: { + median_price_per_m2: number | null; + sample_n: number | null; + window: TxWindow; + }; + }; + distribution: { + asking_sale_per_m2: Percentiles; + transaction_price_per_m2: Percentiles; + }; + quality: { + coverage: "full" | "low_sample" | "no_asking_data" | "suppressed" | "data_stale"; + confidence: "high" | "low"; + as_of: string | null; + stale: boolean; + notes: string[]; + }; +} + +export interface PriceSpreadLocation { + location: string; + county_code: string; + voivodeship: string; + type: "city" | "county"; + asking_sample_n: number; + confidence: "high" | "low"; +} + +export interface PriceSpreadLocationsResponse { + data: PriceSpreadLocation[]; + meta: { total: number; snapshot_date: string | null }; +} + +// ── Demographics (GUS BDL) ────────────────────────────────────────── + +// One indicator series. values is a year-keyed map (single entry for latest-year queries, multiple +// for yearFrom/yearTo time series). variable_id is null for derived metrics. +export interface DemographicsIndicator { + name: string; + unit: string; + variable_id: number | null; + category: string; + level: string; + values: Record; + note?: string; + derived?: boolean; + snapshot?: boolean; + snapshot_note?: string; +} + +export interface DemographicsResponse { + location: { + name: string | null; + country_code: "PL"; + location_type: "city" | "county" | "municipality" | "voivodeship"; + teryt: string; + level: string; + // Parent administrative units; absent in demo mode. Possible keys: powiat, podregion, wojewodztwo. + hierarchy?: Record; + }; + coverage: "full" | "no_data"; + indicators: Record; + meta: { + variables_count: number; + categories: string[]; + levels_included: string[]; + data_source: string; + as_of: string | null; + }; + demo?: boolean; +} + +export function getDemographics( + params: { location?: string; teryt?: string; year?: number; yearFrom?: number; yearTo?: number; category?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/demographics", toQueryParams({ + location: params.location, + teryt: params.teryt, + year: params.year, + yearFrom: params.yearFrom, + yearTo: params.yearTo, + category: params.category, + }), apiKey); +} + +// ── Infrastructure signals ────────────────────────────────────────── + +export interface InfraTender { + title: string; + category: string; + notice_type: string; + published_at: string; + value_pln: number | null; + value_kind: string | null; + /** "high" only when the contracting authority is the municipality itself. */ + attribution_confidence: string | null; + bzp_url: string | null; +} + +export interface InfrastructureSignalsResponse { + location: { + name: string | null; + country_code: "PL"; + location_type: "city" | "county" | "municipality"; + teryt: string; + level: string; + }; + coverage: "full" | "partial" | "no_data"; + tenders: { + window_months: number; + by_category: Record; + recent: InfraTender[]; + truncated: boolean; + }; + /** Membership in an agglomeration of the national urban waste-water treatment programme. */ + kposk: { + in_agglomeration: boolean; + agglomerations: Array<{ name: string; rlm: number | null }>; + truncated: boolean; + }; + capex: { + by_year: Record; + }; + meta: { coverage_note: string; as_of: string | null }; +} + +export function getInfrastructureSignals( + params: { location?: string; teryt?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/infrastructure-signals", toQueryParams({ + location: params.location, + teryt: params.teryt, + }), apiKey); +} + // ── Credit info types ─────────────────────────────────────────────── export interface CreditInfo { @@ -100,14 +409,29 @@ function extractCreditInfo(res: Response): CreditInfo | null { // ── OAuth internal auth ──────────────────────────────────────────── -// SOH char (\x01) is impossible in base64url or ctx_ keys - used as both prefix and separator -const OAUTH_CTX_PREFIX = "\x01"; +// SOH char (\x01) is impossible in base64url or ctx_ keys - used as both prefix and separator. +// Exported so the tool layer can gate on the OAuth channel before decoding (identity logging). +export const OAUTH_CTX_PREFIX = "\x01"; export function encodeOAuthCtx(userId: string, grantId: string): string { // \x01{userId}\x01{grantId} - \x01 cannot appear in UUIDs (hex + hyphens only) return `${OAUTH_CTX_PREFIX}${userId}${OAUTH_CTX_PREFIX}${grantId}`; } +// Inverse of encodeOAuthCtx. Returns null for anything that is not a well-formed OAuth context key +// (not \x01-prefixed, missing separator, empty userId, or empty grantId). Mirrors buildHeaders' +// parse below, but returns null on malformed input instead of throwing - the identity-logging caller +// must never crash a tool call, it just omits user_id. +export function decodeOAuthCtx(key: string): { userId: string; grantId: string } | null { + if (!key.startsWith(OAUTH_CTX_PREFIX)) return null; + const rest = key.slice(OAUTH_CTX_PREFIX.length); + const sepIdx = rest.indexOf(OAUTH_CTX_PREFIX); + if (sepIdx <= 0) return null; // no separator, or empty userId + const grantId = rest.slice(sepIdx + OAUTH_CTX_PREFIX.length); + if (!grantId) return null; // empty grantId + return { userId: rest.slice(0, sepIdx), grantId }; +} + // ── Shared HTTP helpers ──────────────────────────────────────────── function buildHeaders(apiKey?: string): Record { @@ -136,13 +460,42 @@ function buildHeaders(apiKey?: string): Record { return headers; } +/** + * Reads `Retry-After` as whole seconds. Returns null when the header is absent or + * unusable, so the caller can stay silent about timing rather than invent a number. + * + * RFC 7231 allows either a delta in seconds or an HTTP-date; a proxy in front of the + * API may send the date form even though the API itself always sends seconds. + */ +export function parseRetryAfterSeconds(headerValue: string | null | undefined): number | null { + if (!headerValue) return null; + const raw = headerValue.trim(); + if (/^\d+$/.test(raw)) return Math.max(1, parseInt(raw, 10)); + // Only the date form is left, and every HTTP-date names a weekday and a month. Without that + // check `Date.parse` happily turns junk like "-5" into a year and invents a wait out of it. + if (!/[A-Za-z]/.test(raw)) return null; + const asDate = Date.parse(raw); + if (Number.isNaN(asDate)) return null; + return Math.max(1, Math.ceil((asDate - Date.now()) / 1000)); +} + +/** Turns a delay in seconds into the largest unit that still reads naturally. */ +export function formatRetryAfter(seconds: number): string { + const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}`; + if (seconds < 60) return unit(seconds, "second"); + if (seconds < 3600) return unit(Math.ceil(seconds / 60), "minute"); + if (seconds < 86400) return unit(Math.ceil(seconds / 3600), "hour"); + return unit(Math.ceil(seconds / 86400), "day"); +} + async function handleErrorResponse(res: Response, apiKey?: string): Promise { // 429 has special Retry-After handling - keep dedicated path if (res.status === 429) { - const retryAfter = res.headers?.get?.("Retry-After"); - const days = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 86400) : null; - const resetInfo = days !== null ? ` Resets in ${days} day(s).` : ""; - throw new Error(`Too many requests.${resetInfo}`); + const seconds = parseRetryAfterSeconds(res.headers?.get?.("Retry-After")); + // Every 429 the API emits is a short rate limit; an exhausted allowance is a 402. + // Spelling that out stops the caller from reporting a few seconds' wait as "come back later". + const wait = seconds !== null ? ` Retry in ${formatRetryAfter(seconds)}.` : " Retry shortly."; + throw new Error(`Too many requests - this is a rate limit, not an exhausted allowance.${wait}`); } const body = (await res.json().catch(() => ({}))) as ErrorBody; const mode = getAuthMode(apiKey ?? process.env.CENOGRAM_API_KEY); @@ -213,7 +566,7 @@ export async function fetchApiPost( // ── Typed wrappers ────────────────────────────────────────────────── export function getStats(apiKey?: string): Promise> { - return fetchApi("/api/stats", undefined, apiKey); + return fetchApi("/api/v1/stats", undefined, apiKey); } export interface TransactionParams { @@ -224,8 +577,9 @@ export interface TransactionParams { parcelId?: string; propertyType?: number; marketType?: number; - unitFunction?: number; - buildingType?: number; + unitFunction?: number | string; // string carries the "unknown"=NULL sentinel (mapUnitFunction) + buildingType?: number | string; // string carries the "unknown"=NULL sentinel (mapBuildingType) + ownershipType?: number | string; // CSV of registry codes (mapOwnershipTypes; "unknown"=NULL sentinel) mpzpDesignation?: string; minPrice?: number; maxPrice?: number; @@ -234,6 +588,12 @@ export interface TransactionParams { minArea?: number; maxArea?: number; bbox?: string; + transactionType?: string; + rooms?: string; + floor?: string; + floodRisk?: string; + heritageStatus?: string; + landslideRisk?: string; limit?: number; page?: number; sort?: string; @@ -241,7 +601,7 @@ export interface TransactionParams { } export function getTransactions(p: TransactionParams, apiKey?: string): Promise> { - return fetchApi("/api/transactions", toQueryParams({ + return fetchApi("/api/v1/transactions", toQueryParams({ district: p.district, teryt: p.teryt, street: p.street, @@ -250,8 +610,15 @@ export function getTransactions(p: TransactionParams, apiKey?: string): Promise< propertyType: p.propertyType, marketType: p.marketType, unitFunction: p.unitFunction, + ownershipType: p.ownershipType, buildingType: p.buildingType, mpzpDesignation: p.mpzpDesignation, + transactionType: p.transactionType, + rooms: p.rooms, + floor: p.floor, + floodRisk: p.floodRisk, + heritageStatus: p.heritageStatus, + landslideRisk: p.landslideRisk, minPrice: p.minPrice, maxPrice: p.maxPrice, dateFrom: p.dateFrom, @@ -267,15 +634,26 @@ export function getTransactions(p: TransactionParams, apiKey?: string): Promise< } export function getTransactionsSummary(p: TransactionParams, apiKey?: string): Promise> { - return fetchApi("/api/transactions/summary", toQueryParams({ + return fetchApi("/api/v1/transactions/summary", toQueryParams({ district: p.district, teryt: p.teryt, street: p.street, + buildingNumber: p.buildingNumber, + parcelId: p.parcelId, propertyType: p.propertyType, marketType: p.marketType, unitFunction: p.unitFunction, + ownershipType: p.ownershipType, buildingType: p.buildingType, mpzpDesignation: p.mpzpDesignation, + transactionType: p.transactionType, + rooms: p.rooms, + floor: p.floor, + floodRisk: p.floodRisk, + heritageStatus: p.heritageStatus, + // Summary must carry the same row-filtering params as getTransactions — otherwise the + // "Found N" count reports an unfiltered total (same drift guard as floodRisk). + landslideRisk: p.landslideRisk, minPrice: p.minPrice, maxPrice: p.maxPrice, dateFrom: p.dateFrom, @@ -287,11 +665,124 @@ export function getTransactionsSummary(p: TransactionParams, apiKey?: string): P } export function getPricePerM2(apiKey?: string): Promise> { - return fetchApi("/api/price-per-m2", undefined, apiKey); + return fetchApi("/api/v1/price-per-m2", undefined, apiKey); } export function getDistricts(apiKey?: string): Promise> { - return fetchApi("/api/districts", undefined, apiKey); + return fetchApi("/api/v1/districts", undefined, apiKey); +} + +export function getRentalYield( + params: { location?: string; teryt?: string; areaBucket?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/rental-yield", toQueryParams({ + location: params.location, + teryt: params.teryt, + areaBucket: params.areaBucket, + }), apiKey); +} + +export function getRentalYieldLocations( + params: { search?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/rental-yield/locations", toQueryParams({ + search: params.search, + }), apiKey); +} + +export function getPriceSpread( + params: { location?: string; teryt?: string; marketType?: string; areaBucket?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/price-spread", toQueryParams({ + location: params.location, + teryt: params.teryt, + marketType: params.marketType, + areaBucket: params.areaBucket, + }), apiKey); +} + +export function getPriceSpreadLocations( + params: { search?: string }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/price-spread/locations", toQueryParams({ + search: params.search, + }), apiKey); +} + +// ── Valuation (comparable-sales apartment estimate) ───────────────── + +// One comparable transaction echoed by /valuations when includeComps is set. Mirrors the REST envelope +// (inputs.comparables[]). unit_number may be absent from responses; has_unit_number indicates whether +// the unit has a number on record. +export interface ValuationComparable { + distance_m: number; + transaction_date: string; + area_m2: number; + price_per_m2: number; + rooms: number | null; + floor: number | null; + market_type: "primary" | "secondary" | null; + district: string | null; + has_unit_number: boolean; + unit_number?: string | null; +} + +// Envelope from GET /api/valuations (comparable-sales apartment estimate). coverage: "covered" = an +// estimate was produced; "no_data" = too few comparables near the point (credit refunded); "not_covered" +// is reserved for property types the estimate does not serve. Monetary fields are PLN. as_of = transaction-data +// freshness anchor (lags by county). All estimate fields are null on no_data. +export interface ValuationResponse { + location: { country_code: string; lat: number | null; lng: number | null; county_code: string | null }; + metric: string; + currency: string; + segment: { property_type: string; market_type: "primary" | "secondary" | "all"; area_m2: number; rooms: number | null }; + result: { + estimated_value: number | null; + price_per_m2: number | null; + value_range_likely: { low: number | null; high: number | null }; + value_range_wide: { low: number | null; high: number | null }; + confidence: number | null; + confidence_band: "high" | "medium" | "low" | null; + }; + inputs: { + comps_total: number; + radius_m: number | null; + window_months: number; + comparables: ValuationComparable[] | null; + }; + quality: { + coverage: "covered" | "no_data" | "not_covered"; + as_of: string | null; + price_basis: "apartment" | "deed"; + ess: number | null; + accuracy_segment: "wwa" | "duze" | "srednie" | "male" | null; + note: string; + }; +} + +// GET /api/valuations — apartment value estimate from comparable registered transactions near a point. +// Address by lat+lng OR parcelId (the parcel centroid is used); area (m²) is required. Costs 5 credits, +// refunded on coverage:"no_data". +export function getValuation( + params: { lat?: number; lng?: number; parcelId?: string; area: number; rooms?: number; market?: "primary" | "secondary"; includeComps?: boolean }, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/valuations", toQueryParams({ + lat: params.lat, + lng: params.lng, + parcelId: params.parcelId, + area: params.area, + rooms: params.rooms, + market: params.market, + // Tri-state collapse: a falsy includeComps (false OR undefined) omits the param → server default + // (comps excluded). The tool layer always passes an explicit boolean, so this only matters for + // future direct callers: pass true to include comps; anything else means "estimate only". + includeComps: params.includeComps ? "true" : undefined, + }), apiKey); } export interface LocationItem { @@ -302,7 +793,7 @@ export interface LocationItem { } export function getLocations(parent?: string, apiKey?: string): Promise> { - return fetchApi("/api/locations", parent ? { parent } : undefined, apiKey); + return fetchApi("/api/v1/locations", parent ? { parent } : undefined, apiKey); } export function getPriceHistogram( @@ -310,13 +801,15 @@ export function getPriceHistogram( max = 3_000_000, apiKey?: string, ): Promise> { - return fetchApi("/api/stats/price-histogram", toQueryParams({ bins, max }), apiKey); + return fetchApi("/api/v1/stats/price-histogram", toQueryParams({ bins, max }), apiKey); } // ── Parcel search ────────────────────────────────────────────────── export interface ParcelSearchResult { - parcel_id: string; + // Optional in the server contract. This client always calls with an API key, so in practice it is + // present — typed optional anyway, to keep the formatter honest if that ever changes. + parcel_id?: string; district: string | null; area_m2: number | null; lat: number; @@ -332,7 +825,174 @@ export function searchParcels( limit?: number, apiKey?: string, ): Promise> { - return fetchApi("/api/parcels/search", toQueryParams({ q, limit }), apiKey); + return fetchApi("/api/v1/parcels/search", toQueryParams({ q, limit }), apiKey); +} + +// ── Parcel resolve (discovery → cadastral identity) ───────────────── + +// One candidate returned by /parcels/resolve. parcel_id/parcel_key are the cadastral identity; typed +// nullable to match the server contract (may be absent in some responses), so guard against null at the +// render layer. county_code = the 4-digit administrative prefix of the id. +export interface ParcelResolveMatch { + id: string; + parcel_id: string | null; + parcel_key: string | null; + district: string | null; + county_code: string | null; + parcel_number: string | null; + area_m2: number | null; + has_geometry: boolean; + centroid: { lat: number; lng: number } | null; +} + +// Envelope from /parcels/resolve. coverage: covered = ≥1 match; not_covered = no confirmed parcel (the +// credit is refunded). as_of = freshness of our cadastral copy (a global value; matches may span areas). +export interface ParcelResolveResponse { + query: { mode: string; q?: string; parcelId?: string; lat?: number; lng?: number }; + coverage: "covered" | "not_covered"; + as_of: string | null; + matches: ParcelResolveMatch[]; + truncated: boolean; +} + +export interface ResolveParcelParams { + q?: string; + parcelId?: string; + lat?: number; + lng?: number; +} + +// Exactly one of q / parcelId / (lat+lng) — enforced by the caller (tool layer) and the server. +export function resolveParcel( + params: ResolveParcelParams, + apiKey?: string, +): Promise> { + return fetchApi("/api/v1/parcels/resolve", toQueryParams({ + q: params.q, + parcelId: params.parcelId, + lat: params.lat, + lng: params.lng, + }), apiKey); +} + +// ── Parcel report (composite dossier) ─────────────────────────────── + +// One price level (county or locality) inside market_context. coverage is the STATISTICAL canon +// (full/low_sample/suppressed/no_data), NOT the four-state — a context section, not a per-parcel one. +// median_price_per_m2 is null when suppressed (too few sales) or no_data. n = the sample size behind it. +export interface ReportMarketLevel { + coverage: string; + median_price_per_m2: number | null; + n: number; + county_code?: string | null; + district?: string | null; +} + +// Local price context: 12-month median zł/m² at the county and locality grain. coverage rolls up the +// two levels (statistical canon). as_of is null here (aggregates carry no per-location freshness marker). +export interface ReportMarketContext { + coverage: string; + as_of: string | null; + county: ReportMarketLevel; + locality: ReportMarketLevel; + note?: string | null; +} + +// The demographics half of location_context: a headline GUS subset for the gmina. coverage is the +// statistical canon (full/no_data). indicators is a name-keyed map (empty when no_data). +export interface ReportDemographicsSection { + coverage: string; + as_of: string | null; + name: string | null; + indicators: Record; +} + +// The infrastructure-signals half of location_context: the three upcoming-investment overlays for the +// gmina. coverage is full/partial/no_data (statistical canon). Shape mirrors InfrastructureSignalsResponse +// minus its `location` envelope (the gmina identity lives on location_context.gmina_teryt). +export interface ReportInfraSection { + coverage: string; + as_of: string | null; + gmina_name: string | null; + tenders: { window_months: number; by_category: Record; recent: InfraTender[]; truncated: boolean }; + kposk: { in_agglomeration: boolean; agglomerations: Array<{ name: string; rlm: number | null }>; truncated: boolean }; + capex: { by_year: Record }; + note?: string | null; +} + +// Municipal context: the demographics + infrastructure-signals bundle for the parcel's gmina. coverage +// rolls up the two sub-sections (statistical canon). gmina_teryt is null for a parcel with no cadastral id. +export interface ReportLocationContext { + coverage: string; + as_of: string | null; + gmina_teryt: string | null; + demographics: ReportDemographicsSection; + infra_signals: ReportInfraSection; + note?: string | null; +} + +// One per-parcel enrichment section (flood/heritage/landslide/surroundings/transit/planning/buildings/ +// permits/farmland/transactions). Every section carries its own FOUR-STATE coverage + as_of, then its +// layer-specific fields. Typed open (index signature) because each layer contributes a different field +// set — the formatter reads coverage/as_of plus a handful of known keys defensively. +export interface ReportSection { + coverage: string; + as_of: string | null; + note?: string | null; + [key: string]: unknown; +} + +// Envelope from GET /api/parcels/:key/report — the composite parcel dossier. Top-level `coverage` is the +// four-state of the parcel CORE (covered / not_covered / not_computed). `sections` bundles the 9 enrichment +// layers + transaction history (each four-state) and the two context sections (statistical canon). `billing` +// carries the net outcome: charged + refunded (their sum is the gross for a non-demo caller) and a `rule` +// naming why (full / core_floor / total_miss_refund / not_computed_refund / disabled / demo). +export interface ParcelReportResponse { + parcel: { + id: string | null; + parcel_id: string | null; + parcel_key: string | null; + district?: string | null; + county_code?: string | null; + county_name?: string | null; + voivodeship_name?: string | null; + parcel_number?: string | null; + area_m2?: number | null; + land_use?: string | null; + mpzp_designation?: string | null; + has_geometry?: boolean; + centroid?: { lat: number; lng: number } | null; + }; + coverage: string; + as_of: string | null; + sections: { + transactions: ReportSection; + flood: ReportSection; + heritage: ReportSection; + landslide: ReportSection; + surroundings: ReportSection; + transit: ReportSection; + planning: ReportSection; + buildings: ReportSection; + permits: ReportSection; + farmland: ReportSection; + market_context: ReportMarketContext; + location_context: ReportLocationContext; + }; + billing: { charged: number; refunded: number; rule: string }; + note?: string | null; +} + +// GET /api/parcels/:key/report — the whole parcel dossier in one call. The path key must be the URL-safe +// dash form (a '/' in the number segment becomes '-'); we normalise a raw-slash id here so callers can pass +// the natural '142907_2.0014.342/5' form. A UUID passes through unchanged. Costs 35 API tokens, refunded in +// full or in part by outcome (see billing.rule on the response). +export function getParcelReport( + parcelKey: string, + apiKey?: string, +): Promise> { + const urlKey = parcelKey.trim().replace(/\//g, "-"); + return fetchApi(`/api/v1/parcels/${encodeURIComponent(urlKey)}/report`, undefined, apiKey); } // ── Spatial search (polygon) ─────────────────────────────────────── @@ -341,8 +1001,9 @@ export interface SpatialSearchParams { polygon: { type: "Polygon"; coordinates: number[][][] }; propertyType?: number; marketType?: number; - unitFunction?: number; - buildingType?: number; + unitFunction?: number | string; // string carries the "unknown"=NULL sentinel (mapUnitFunction) + buildingType?: number | string; // string carries the "unknown"=NULL sentinel (mapBuildingType) + ownershipType?: number | string; // CSV of registry codes (mapOwnershipTypes; "unknown"=NULL sentinel) mpzpDesignation?: string; minPrice?: number; maxPrice?: number; @@ -352,6 +1013,9 @@ export interface SpatialSearchParams { maxArea?: number; district?: string; street?: string; + transactionType?: string; + rooms?: string; + floor?: string; limit?: number; } @@ -366,11 +1030,36 @@ export interface SpatialFeatureProperties { rooms: number | null; floor: number | null; street: string | null; + // Street provenance — see Transaction.address_source. /spatial returns it + // (the API) and formatSpatialFeature spreads it through; type was out-of-sync with runtime. + address_source?: "rcn" | "approx_high" | "approx_low" | "none" | null; building_number: string | null; city: string | null; district: string | null; parcel_area: number | null; parcel_number: string | null; + share_basis?: "full" | "fraction" | "ambiguous" | null; + // Garage provenance — see Transaction.is_garage / area_basis. + is_garage?: boolean | null; + area_basis?: "unit" | "building" | null; + // ── Provenance signals + raw deed fields — see Transaction for semantics. ── + parcel_count?: number | null; + area_is_ha_converted?: boolean | null; + property_type_inferred?: boolean | null; + property_type_reclassed?: boolean | null; + ownership_type?: number | null; + ownership_share?: string | null; + seller_type?: number | null; + buyer_type?: number | null; + land_use?: string | null; + unit_price?: number | string | null; + vat?: number | string | null; + // Building attrs — see Transaction. Carried on /spatial since the fix that added them + // to the GeoJSON properties; gate on building_count first. + building_count?: number | null; + footprint_area_m2?: number | null; + est_total_area_m2?: number | null; + building_storeys?: number | null; } export interface SpatialFeature { @@ -395,6 +1084,7 @@ export function searchByPolygon( if (p.marketType != null) body.marketType = p.marketType; if (p.unitFunction != null) body.unitFunction = p.unitFunction; if (p.buildingType != null) body.buildingType = p.buildingType; + if (p.ownershipType != null) body.ownershipType = p.ownershipType; if (p.mpzpDesignation) body.mpzpDesignation = p.mpzpDesignation; if (p.minPrice != null) body.minPrice = p.minPrice; if (p.maxPrice != null) body.maxPrice = p.maxPrice; @@ -404,12 +1094,27 @@ export function searchByPolygon( if (p.maxArea != null) body.maxArea = p.maxArea; if (p.district) body.district = p.district; if (p.street) body.street = p.street; + if (p.transactionType) body.transactionType = p.transactionType; + if (p.rooms) body.rooms = p.rooms; + if (p.floor) body.floor = p.floor; if (p.limit != null) body.limit = p.limit; - return fetchApiPost("/api/transactions/spatial", body, apiKey); + return fetchApiPost("/api/v1/transactions/spatial", body, apiKey); } // ── Compare locations ────────────────────────────────────────────── +// One demographics indicator on a compare entry (?include=demographics). Curated top-10 + a few +// cross-source derived metrics. Present only when enrichment was requested AND the district resolved +// to a county — REST omits the whole `demographics` key for unresolved districts. +export interface CompareDemographicsIndicator { + value: number | null; + year: number | null; + unit: string; + derived?: boolean; + cross_source?: boolean; + note?: string; +} + export interface CompareEntry { median_price_m2: number | null; avg_area: number | null; @@ -417,6 +1122,7 @@ export interface CompareEntry { max_date: string | null; total: number; suggestions?: string[]; + demographics?: Record; } export type CompareResponse = Record; @@ -425,8 +1131,9 @@ export interface CompareParams { districts: string; propertyType?: number; marketType?: number; - unitFunction?: number; - buildingType?: number; + unitFunction?: number | string; // string carries the "unknown"=NULL sentinel (mapUnitFunction) + buildingType?: number | string; // string carries the "unknown"=NULL sentinel (mapBuildingType) + ownershipType?: number | string; // CSV of registry codes (mapOwnershipTypes; "unknown"=NULL sentinel) mpzpDesignation?: string; minPrice?: number; maxPrice?: number; @@ -435,19 +1142,29 @@ export interface CompareParams { minArea?: number; maxArea?: number; street?: string; + transactionType?: string; + rooms?: string; + floor?: string; + // Enrichment layers — comma-separated on the wire, per the API's list-param convention. + include?: string; } export function compareLocations( p: CompareParams, apiKey?: string, ): Promise> { - return fetchApi("/api/transactions/summary/compare", toQueryParams({ + return fetchApi("/api/v1/transactions/summary/compare", toQueryParams({ districts: p.districts, + include: p.include, propertyType: p.propertyType, marketType: p.marketType, unitFunction: p.unitFunction, + ownershipType: p.ownershipType, buildingType: p.buildingType, mpzpDesignation: p.mpzpDesignation, + transactionType: p.transactionType, + rooms: p.rooms, + floor: p.floor, minPrice: p.minPrice, maxPrice: p.maxPrice, dateFrom: p.dateFrom, @@ -457,3 +1174,332 @@ export function compareLocations( street: p.street, }), apiKey); } + +// ── Building breakdown (per-transaction, per-building) ────────────── + +// One building linked to a transaction. Mirrors the /api/transactions/:id/buildings row contract. +// NUMERIC columns arrive as strings over the wire (Intl.format coerces). Field names are neutral — +// no source register is named. footprint_area_alt_m2 / footprint_divergent are NULL unless a +// second independent footprint measurement exists; est_total_area_m2 is NULL without footprint+storeys. +export interface BuildingBreakdownRow { + building_type: number | null; + footprint_area_m2: number | null; + footprint_area_alt_m2: number | null; + footprint_divergent: boolean | null; + storeys: number | null; + est_total_area_m2: number | null; + match_confidence: string | null; +} + +export interface BuildingBreakdownResponse { + data: BuildingBreakdownRow[]; + truncated: boolean; +} + +// No encodeURIComponent: the tool layer validates transactionId as a UUID (zod regex) before this +// runs, so it is always [0-9a-f-] — the other path-param wrappers here don't escape either. +export function getBuildingBreakdown( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/buildings`, undefined, apiKey); +} + +// ── Flood-zone breakdown (per-transaction, per-parcel) ────────────── + +// One flood scenario carried inside a parcel row's `scenarios` array. Bounded: the hazard model has only a +// handful of scenario types (river/coastal at a few return periods, plus defence-failure variants). +// depthClass is always null: the hazard data carries no depth detail. +export interface FloodScenario { + scenario: string | null; + source: string | null; + returnPeriod: number | null; + severity: number; + isLeveeFailure: boolean; + depthClass: string | null; +} + +// One linked parcel that sits in a mapped flood-hazard zone. Mirrors the /api/transactions/:id/flood row +// contract. TWO-STATE: a row exists ONLY for an in-zone parcel — absence of rows is never asserted as +// "safe". pct_in_zone is NUMERIC → string over the wire (formatters coerce). nearest_zone_m is a constant +// 0 placeholder — every row already intersects a zone, so there is no distance to report. depth_class is null. +export interface FloodBreakdownRow { + flood_risk: "high" | "medium" | "low" | null; + severity_rank: number | null; + worst_scenario: string | null; + source: string | null; + depth_class: string | null; + pct_in_zone: number | string | null; + nearest_zone_m: number | null; + scenarios: FloodScenario[] | null; +} + +export interface FloodBreakdownResponse { + data: FloodBreakdownRow[]; + truncated: boolean; +} + +// No encodeURIComponent: the tool layer validates transactionId as a UUID (zod regex) before this runs, +// so it is always [0-9a-f-] — the sibling path-param wrappers here don't escape either. +export function getTransactionFlood( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/flood`, undefined, apiKey); +} + +// ── Heritage-listing breakdown (per-transaction, per-parcel) ──────── + +// One heritage entry carried inside a parcel row's `sites` array (capped server-side). Neutral EN +// labels mapped server-side; category is a bounded vocabulary (building, urban_layout, surroundings, +// cemetery, park, ensemble, landscape, other) — typed open (string) for forward compatibility, with +// 'other' as the server's fallback. name may be absent (field-level gating server-side). +export interface HeritageSite { + category: string; + name?: string | null; + function: string | null; + period: string | null; + entry_date: string | null; +} + +// One linked parcel with a detected heritage listing. Mirrors the /api/transactions/:id/heritage row +// contract. TWO-STATE: a row exists ONLY when a listing was detected — absence of rows is never +// asserted as "not listed". pct_in_zone is NUMERIC → string over the wire (formatters coerce); it is +// null when only point/line-located entries matched (no areal geometry to measure coverage against). +export interface HeritageBreakdownRow { + heritage_status: "listed" | "zone" | null; + severity_rank: number | null; + pct_in_zone: number | string | null; + site_count: number | null; + sites: HeritageSite[] | null; +} + +export interface HeritageBreakdownResponse { + data: HeritageBreakdownRow[]; + truncated: boolean; +} + +// ── Landslide-zone breakdown (per-transaction, per-parcel) ────────── + +// One mapped hazard record carried inside a parcel row's `zones` array. Bounded: only two kinds exist +// and records are deduplicated per (kind, source_version_date), so the array stays a handful of compact +// entries. source_version_date = the source-record VERSION date, NOT a survey/observation date. +export interface LandslideZone { + kind: string | null; + source_version_date: string | null; +} + +// One linked parcel that intersects a mapped landslide-hazard zone (official 1:10,000-scale maps). +// Mirrors the /api/transactions/:id/landslide row contract. TWO-STATE: a row exists ONLY for an +// in-zone parcel — absence of rows is never asserted as "safe". pct_in_zone is NUMERIC → string over +// the wire (formatters coerce). +export interface LandslideBreakdownRow { + landslide_risk: "landslide" | "threatened" | null; + severity_rank: number | null; + pct_in_zone: number | string | null; + zones: LandslideZone[] | null; +} + +export interface LandslideBreakdownResponse { + data: LandslideBreakdownRow[]; + truncated: boolean; +} + +// ── Public-transport access breakdown (per-transaction, per-parcel) ─ + +// One linked parcel's nearest-stop distance + name per mode. Mirrors the /api/transactions/:id/transit row +// contract. TWO-STATE: a row exists ONLY when at least one mode is within its cap — a null column means no +// stop of that mode within the cap, NOT "no transit access" (open feeds don't cover every rural gmina). +export interface TransitBreakdownRow { + rail_distance_m: number | null; + rail_stop_name: string | null; + metro_distance_m: number | null; + metro_stop_name: string | null; + tram_distance_m: number | null; + tram_stop_name: string | null; + bus_distance_m: number | null; + bus_stop_name: string | null; +} + +export interface TransitBreakdownResponse { + data: TransitBreakdownRow[]; + truncated: boolean; +} + +// ── Building-permit breakdown (per-transaction, per-parcel) ────────── + +// One positively-resolved case (permit or notification) registered against one of the +// transaction's parcels. Mirrors the /api/transactions/:id/permits row contract. TWO-STATE: +// a row exists ONLY for a registered case — an empty list is never asserted as "nothing was +// ever planned". The response carries NO parcel identity and NO source-registry number by +// design (identity-stripped). record_kind: permit = building-permit decision, notification = works +// notification. intent_type / works_type / status are neutral English codes (unknown source +// values map to "other"); object_category is the statutory Roman-numeral class; authority / +// address / volume are administrative facts. Dates are YYYY-MM-DD strings; notifications have +// no decision_date. +export interface PermitRecord { + record_kind: "permit" | "notification"; + intent_type: string | null; + object_category: string | null; + works_type: string | null; + status: string | null; + decision_date: string | null; + intake_date: string | null; + authority: string | null; + address_street: string | null; + address_number: string | null; + address_city: string | null; + volume_m3: number | null; +} + +export interface PermitsResponse { + data: PermitRecord[]; + truncated: boolean; + // Two-state disclaimer, shipped on every response (including empty). + note?: string; +} + +// No encodeURIComponent: the tool layer validates transactionId as a UUID (zod regex) before this runs, +// so it is always [0-9a-f-] — the sibling path-param wrappers here don't escape either. +export function getTransactionHeritage( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/heritage`, undefined, apiKey); +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionLandslide( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/landslide`, undefined, apiKey); +} + +// ── Surroundings (per-transaction, per-parcel nuisance distances) ─── + +// One linked plot with the distance (meters, from the plot boundary) to the nearest object of each +// nuisance category. TWO-STATE: a null distance = no such object within that category's search radius +// in the reference data — NEVER asserted as "none exists". assessed=false = the plot has not been +// evaluated yet (all distances null then). Distances may arrive as strings over the wire +// (formatters coerce), mirroring the flood row contract. +export interface SurroundingsRow { + assessed: boolean; + cemetery_distance_m: number | string | null; + landfill_distance_m: number | string | null; + sewage_treatment_distance_m: number | string | null; + industrial_area_distance_m: number | string | null; + industrial_plant_distance_m: number | string | null; + livestock_farm_distance_m: number | string | null; +} + +export interface SurroundingsResponse { + data: SurroundingsRow[]; + truncated?: boolean; +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionSurroundings( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/surroundings`, undefined, apiKey); +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionTransit( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/transit`, undefined, apiKey); +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionPermits( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/permits`, undefined, apiKey); +} + +// ── General-plan (POG) planning-zone breakdown (per-transaction) ──── + +// One planning-zone or overlay row for a transaction's land. kind = 'zone' (a base planning zone, +// carrying a symbol + name + building parameters) or an overlay area ('infill_area' = an infill +// development area / obszar uzupełnienia zabudowy, 'downtown_area' = a central-development area); +// overlays carry a coverage share only, no symbol/parameters. pct_of_parcel and the four building +// parameters are numeric but may arrive as strings over the wire (formatters coerce). params_mixed = +// this row merges sub-zones of one symbol whose building parameters were not all in agreement, so an +// ambiguous parameter is reported as null rather than guessed. parcel_ord is an opaque 1..N ordinal +// grouping the rows of one land parcel (no cadastral identity): a transaction spanning several parcels +// repeats a zone symbol once per parcel, and parcel_ord is what tells those repetitions apart. +export interface PlanningRow { + parcel_ord: number; + kind: string; + zone_symbol: string | null; + zone_name: string | null; + pct_of_parcel: number | string | null; + max_building_height_m: number | string | null; + max_development_intensity: number | string | null; + max_built_up_coverage_pct: number | string | null; + min_bio_active_area_pct: number | string | null; + params_mixed: boolean; + effective_from: string | null; +} + +// Response of /api/transactions/:id/planning. THREE-STATE coverage (NOT the two-state hazard pattern): +// 'covered' — planning-zone data was returned for the transaction's land (data non-empty) +// 'covered_no_data' — the municipality has an adopted general plan, but no zone data covers these +// parcels in our sources yet (data empty, but the municipality is mapped) +// 'not_covered' — no published general-plan data for this municipality yet (data empty) +// 'not_covered' is NEVER a claim that the municipality has no plan — coverage grows as plans are +// adopted and published nationwide. parcels_total / parcels_covered are the coverage counters. +export interface PlanningResponse { + data: PlanningRow[]; + truncated: boolean; + coverage: "covered" | "covered_no_data" | "not_covered"; + parcels_total: number; + parcels_covered: number; + note: string | null; +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionPlanning( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/planning`, undefined, apiKey); +} + +// ── Farmland (agricultural land-eligibility) breakdown (per-transaction, per-parcel) ── + +// One linked parcel matched against official nationwide agricultural land-eligibility data (updated +// weekly). eligible_area_m2 = the eligible agricultural area matched onto the parcel; feature_count = how +// many source features compose it; pct_of_parcel = that area as a share of the parcel's measured area +// (integer 0–100), or null when the parcel's measured area is unavailable. TWO-STATE: a row exists ONLY +// for a parcel with a positive matched area — absence of a row is never asserted as "not agricultural". +export interface FarmlandRow { + eligible_area_m2: number; + pct_of_parcel: number | null; + feature_count: number; +} + +// Response envelope. Diverges from the sibling {data,truncated} shape on purpose: the coverage counters +// and freshness date carry signal this layer needs. parcels_total = linked parcels; parcels_with_data = +// linked parcels with a matched eligible area; as_of = the source snapshot date (YYYY-MM-DD), null when +// there are no rows. +export interface FarmlandResponse { + data: FarmlandRow[]; + truncated: boolean; + parcels_total: number; + parcels_with_data: number; + as_of: string | null; +} + +// No encodeURIComponent: transactionId is zod-validated as a UUID at the tool layer (see above). +export function getTransactionFarmland( + transactionId: string, + apiKey?: string, +): Promise> { + return fetchApi(`/api/v1/transactions/${transactionId}/farmland`, undefined, apiKey); +} diff --git a/src/error-messages.ts b/src/error-messages.ts index 7f29743..ee65463 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -1,3 +1,5 @@ +import { channelSrc } from "./transport-mode.js"; + // SOH char (\x01) - must match OAUTH_CTX_PREFIX in api-client.ts const OAUTH_CTX_PREFIX = "\x01"; @@ -10,37 +12,129 @@ export function getAuthMode(apiKey?: string): AuthMode { return "stdio_env"; } +// Public page that hands out an API key - reachable without an account, unlike /ustawienia. +// The `src` tag is built here rather than taken from the API's own signup_url, which carries a tag +// of its own. A function rather than a constant so the tag follows the active transport at call +// time instead of being pinned at import. +export function signupUrl(): string { + return `https://cenogram.pl/api?src=${channelSrc()}`; +} + export interface ErrorBody { error?: string; + message?: string; // Fastify AJV validation errors: { statusCode, error: "Bad Request", message: "..." } currentBalance?: number; creditsRequired?: number; + upgrade?: string; // 402 trial_expired: where the caller's owner can lift the block + successor?: string; // 410: path that replaced the retired one +} + +/** + * The API's own wording for this failure, when it has one. + * + * Convention across the API: `message` is human-readable, `error` is a code or category, so + * `message` wins and `error` is the fallback. Three body shapes reach us: + * 1. Custom reply.send: { error: "Maximum 5 districts allowed" } - only `error` + * 2. Thrown plain obj via the global handler: { statusCode, message: "..." } - only `message` + * 3. Fastify AJV: { statusCode, error: "FastifyError", message: "body/x ..." } - `error` is a class name + * Anything longer than 500 chars is dropped rather than truncated - a half-sentence misleads. + * Empty string means "the API said nothing usable", which every branch below treats as its cue + * to fall back to its own text. + */ +function specificMessage(body: ErrorBody): string { + const rawError = typeof body.error === "string" ? body.error.trim() : ""; + const rawMessage = typeof body.message === "string" ? body.message.trim() : ""; + const candidate = rawMessage || rawError; + return candidate.length > 0 && candidate.length <= 500 ? candidate : ""; +} + +/** Lets a relayed sentence sit in front of another one without running into it. */ +function sentence(text: string): string { + return /[.!?]$/.test(text) ? text : `${text}.`; +} + +/** Same guard as above for the auxiliary URL/path fields. */ +function specificField(value: unknown): string { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= 500 ? trimmed : ""; } export function authErrorMessage(status: number, mode: AuthMode, body: ErrorBody = {}): string { + const specific = specificMessage(body); + switch (status) { case 401: if (mode === "oauth") { return "Connection to Cenogram expired or was revoked. In Claude open: Settings > Connectors > Cenogram, disconnect and reconnect."; } - return "API key rejected. Check https://cenogram.pl/api/keys if it's still active."; + if (mode === "none") { + // No key was configured at all, so this is not a broken account - it is a caller who has + // not got one yet. Sending them to /ustawienia (behind a login) would be a dead end. + return ( + `No Cenogram API key configured. Get a free key at ${signupUrl()}, ` + + "then set it as the CENOGRAM_API_KEY environment variable of this MCP server." + ); + } + return "API key rejected. Check https://cenogram.pl/ustawienia#api-keys if it's still active."; case 402: { + // The trial gate freezes an account rather than emptying it, so "insufficient credits" + // (balance vs cost) describes the wrong problem - a balance of 300 with a cost of 1 still + // fails. The API states that case itself; relay it instead of doing arithmetic on it. + // Only `message` will do here: `specific` falls back to `error`, which in this branch is + // the bare code "trial_expired" - relaying that would be worse than the wrong template. + const explanation = specificField(body.message); + if (body.error === "trial_expired" && explanation) { + const upgrade = specificField(body.upgrade); + return upgrade && !explanation.includes(upgrade) ? `${sentence(explanation)} (${upgrade})` : explanation; + } const balance = body.currentBalance ?? 0; const required = body.creditsRequired ?? "?"; if (mode === "oauth") { - return `Insufficient credits (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api#cennik`; + return `Insufficient credits (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api?src=${channelSrc()}#cennik`; } - return `Insufficient credits for key's account (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api#cennik`; + return `Insufficient credits for key's account (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api?src=${channelSrc()}#cennik`; } case 403: if (body.error === "email_not_verified") { return "Account email not verified. Check your inbox, click the activation link, then retry."; } - return `Access denied (HTTP 403).`; + // 403 covers unrelated causes - a rate-limit ban, the demo cap, a plan restriction - and + // each of them ships its own explanation. Swallowing them left the caller guessing. + return specific ? `Access denied: ${specific}` : "Access denied (HTTP 403)."; + + case 503: { + // Not always maintenance: also a disabled feature, a read-only failover, an unavailable + // dataset. Keep the English frame (the API's own text may be Polish) but relay the reason. + if (!specific) return "Cenogram temporarily unavailable. Try again shortly."; + // Several of these bodies already end with their own "try again in a few minutes", in + // Polish or English. Adding ours on top would put two retry hints in one sentence. + const saysRetry = /try again|retry|spr[oó]buj ponownie/i.test(specific); + return `Cenogram temporarily unavailable: ${sentence(specific)}${saysRetry ? "" : " Try again shortly."}`; + } + + case 410: { + // Permanent by definition - the one status where "try again shortly" is actively harmful, + // because a retry loop can never succeed. Reaching it means this package is calling an + // endpoint that no longer exists, so the fix is an upgrade, not a retry. + const successor = specificField(body.successor); + const reason = sentence(specific || "This endpoint has been retired."); + const where = successor ? ` It was replaced by ${successor}.` : ""; + return `${reason}${where} This is permanent - retrying will not help. Update @cenogram/mcp-server to the latest version.`; + } + + case 404: + // 404 from this API is always an unknown resource (e.g. a location/county code that + // does not exist), never a transient outage — so frame it as a client error the caller + // should correct, not a "retry later". The API's message echoes only the caller's own + // input, e.g. "Unknown location: X". + return specific || "Not found (HTTP 404). Check the location name or TERYT code."; - case 503: - return "Cenogram temporarily unavailable (maintenance mode). Try again shortly."; + case 400: + case 422: + return specific ? `Invalid request: ${specific}` : `Invalid request (HTTP ${status}). Check parameters.`; default: return `Cenogram API unavailable (HTTP ${status}). Try again shortly.`; diff --git a/src/formatters.ts b/src/formatters.ts index 41c1b87..d37c935 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -1,5 +1,92 @@ -import type { Transaction, TransactionsResponse, TransactionsSummary, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, SpatialSearchResponse, SpatialFeature, CompareResponse, LocationItem } from "./api-client.js"; -import { PROPERTY_TYPES, MARKET_TYPES } from "./mappings.js"; +import type { Transaction, TransactionsResponse, TransactionsSummary, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, ParcelResolveResponse, SpatialSearchResponse, SpatialFeature, CompareResponse, LocationItem, RentalYieldResponse, RentalYieldLocationsResponse, PriceSpreadResponse, PriceSpreadLocationsResponse, ValuationResponse, ValuationComparable, Percentiles, TxWindow, BuildingBreakdownResponse, FloodBreakdownResponse, HeritageBreakdownResponse, LandslideBreakdownResponse, SurroundingsResponse, SurroundingsRow, TransitBreakdownResponse, PermitsResponse, PlanningResponse, PlanningRow, FarmlandResponse, DemographicsResponse, DemographicsIndicator, InfrastructureSignalsResponse, ParcelReportResponse, ReportSection, ReportMarketContext, ReportMarketLevel, ReportLocationContext } from "./api-client.js"; +import { PROPERTY_TYPES, MARKET_TYPES, BUILDING_TYPES, OWNERSHIP_TYPES, PARTY_TYPES, LAND_USES } from "./mappings.js"; + +// Cross-link shown once on a transaction list when ≥1 row carries building data — tells the LLM the +// transaction id can be expanded into a per-building split via the dedicated tool. +const BUILDING_BREAKDOWN_TIP = + "Tip: call get_building_breakdown(transaction_id) for per-building detail (footprint, storeys, est. area)."; + +// Cross-link shown once on a transaction list when ≥1 row has a mapped flood risk. +const FLOOD_BREAKDOWN_TIP = + "Tip: call get_transaction_flood(transaction_id) for the per-parcel flood-zone breakdown (scenario, hazard source, share in zone)."; + +// Flood-hazard category → return-period note. high = most frequent flood, low = rarest. +// Used both inline (search list) and in the per-parcel breakdown. Neutral wording — never asserts safety. +const FLOOD_RISK_NOTE: Record = { + high: "~1-in-10-year", + medium: "~1-in-100-year", + low: "~1-in-500-year", +}; + +// Cross-link shown once on a transaction list when ≥1 row has a detected heritage listing. +const HERITAGE_BREAKDOWN_TIP = + "Tip: call get_transaction_heritage(transaction_id) for the per-parcel heritage-listing breakdown (entries, category, share in protected area)."; + +// Heritage status → short meaning note. listed = a protected monument on/at the parcel itself; +// zone = the parcel lies within a protected urban layout or the designated surroundings of a monument. +// Used both inline (search list) and in the per-parcel breakdown. Neutral wording — never asserts +// that a property without a detected listing is free of heritage protection. +const HERITAGE_STATUS_NOTE: Record = { + listed: "protected monument on/at the parcel", + zone: "within a protected urban layout or monument surroundings", +}; + +// Indicative-data disclaimer rendered with heritage output. Neutral — no source register is named. +const HERITAGE_DISCLAIMER = + "Indicative data — the regional heritage conservator makes the final, binding determination."; + +// Cross-link shown once on a transaction list when ≥1 row has a mapped landslide risk. +const LANDSLIDE_BREAKDOWN_TIP = + "Tip: call get_transaction_landslide(transaction_id) for the per-parcel landslide-zone breakdown (category, share in zone, source-record version date)."; + +// Landslide-hazard category → readable meaning, from official landslide-hazard maps (1:10,000 scale). +// Used both inline (search list) and in the per-parcel breakdown. Neutral wording — never asserts +// safety; an intersection means the parcel overlaps a mapped hazard area, not that the parcel itself +// is a landslide. +const LANDSLIDE_RISK_NOTE: Record = { + landslide: "a mapped landslide area", + threatened: "an area threatened by mass movements", +}; + +// Area-bucket suffix for headers ("(40-50 m2)"); empty for 'all'/missing. +function areaBucketSuffix(bucket: string | null | undefined): string { + return bucket && bucket !== "all" ? ` (${bucket} m2)` : ""; +} + +// Trailing transaction window as a readable suffix, or "" when either bound is missing. +function windowSuffix(w: TxWindow): string { + return w.from && w.to ? ` (${w.from} to ${w.to})` : ""; +} + +// Active-offer snapshot as a readable suffix (a point in time, not a window), or "" when missing. +function offerDateSuffix(date: string | null): string { + return date ? ` (as of ${date})` : ""; +} + +// One-line percentile ladder, or null when the side has no data (suppressed/missing). +function formatPercentileLadder(p: Percentiles): string | null { + if ([p.p10, p.p25, p.p50, p.p75, p.p90].every((v) => v == null)) return null; + const f = (v: number | null) => (v == null ? "—" : formatPLN(v)); + return `p10 ${f(p.p10)} · p25 ${f(p.p25)} · p50 ${f(p.p50)} · p75 ${f(p.p75)} · p90 ${f(p.p90)} /m2`; +} + +// Distribution block lines (empty array when neither side has data). Each endpoint passes its own +// asking-side ladder (rent-monthly for yield, sale for spread) + the shared transaction ladder. +function distributionLines(asking: Percentiles, tx: Percentiles): string[] { + const ask = formatPercentileLadder(asking); + const txLadder = formatPercentileLadder(tx); + if (!ask && !txLadder) return []; + const out = ["", "Distribution (price per m2):"]; + if (ask) out.push(` Asking: ${ask}`); + if (txLadder) out.push(` Transaction: ${txLadder}`); + return out; +} + +// Market-price methodology caveat. Median/avg price aggregates exclude +// fractional ownership shares and non-market deeds; transaction counts/coverage stay full. +// Reused in tool descriptions (tools.ts) and rendered into price-tool output. +export const MARKET_CAVEAT = + "Note: median/average prices are market-based — fractional ownership shares and non-market deeds (public tenders, foreclosures, privileged/subsidized sales) are excluded from price aggregates. Transaction counts and coverage stay complete."; // ── Primitives ────────────────────────────────────────────────────── @@ -23,9 +110,24 @@ export function formatNumber(value: number | null | undefined): string { return new Intl.NumberFormat("pl-PL").format(value); } +// Like formatPLN but keeps up to 2 decimals - for small per-m² values (e.g. monthly rent +// ~60-80 PLN) where rounding to whole złoty would make a displayed "× 12" not add up. +function formatPLNExact(value: number | null | undefined): string { + if (value == null) return "N/A"; + return new Intl.NumberFormat("pl-PL", { + style: "currency", + currency: "PLN", + maximumFractionDigits: 2, + }).format(value); +} + // ── Shared transaction formatting ────────────────────────────────── interface FormattableFields { + // Transaction id (UUID). Surfaced for EVERY transaction: the model uses it both to + // call get_building_breakdown(id) and to build a cenogram.pl deep link (#...&tx=). Kept + // optional so callers that don't pass it stay unaffected. + id?: string; transaction_date: string; property_type: number; market_type: number; @@ -36,12 +138,52 @@ interface FormattableFields { floor: number | null; district: string | null; street: string | null; + // Provenance of `street`: 'rcn' = from the notarial deed/registry; + // 'approx_high'/'approx_low' = approximated by us (not from the deed); 'none' = no street. + // Surfaced so the model never presents an approximated street as registry-sourced. + address_source?: "rcn" | "approx_high" | "approx_low" | "none" | null; building_number: string | null; city: string | null; parcel_area: number | null; parcel_number?: string | null; county_name?: string | null; voivodeship_name?: string | null; + share_basis?: "full" | "fraction" | "ambiguous" | null; + // Provenance signals + raw deed fields. Signals drive computed-markers on + // parcel_area / property_type; raw fields render (gated) in the extra block. See api-client Transaction. + parcel_count?: number | null; + area_is_ha_converted?: boolean | null; + property_type_inferred?: boolean | null; + property_type_reclassed?: boolean | null; + ownership_type?: number | null; + ownership_share?: string | null; + seller_type?: number | null; + buyer_type?: number | null; + land_use?: string | null; + unit_price?: number | string | null; + vat?: number | string | null; + // Garage provenance. is_garage = the unit is a garage/parking space (opt-in via + // unitFunction=garage). area_basis describes what usable_area_m2 measures for a garage: + // 'unit' = a plausible single parking-spot area (price/m² is computed); 'building' = the whole + // garage-building footprint (the source records the whole building, not the spot) → price/m² is omitted. + is_garage?: boolean | null; + area_basis?: "unit" | "building" | null; + // Building attrs. Neutral names (no source register). Gate on building_count first + // (NULL = no buildings, not 0). Footprint/est are NUMERIC → string over the wire (formatArea coerces). + building_count?: number | null; + footprint_area_m2?: number | null; + est_total_area_m2?: number | null; + building_storeys?: number | null; + // Flood-hazard. TWO-STATE: a category is set ONLY when a linked parcel sits in a mapped + // flood-hazard zone; null/absent is never rendered as "safe". flood_assessed is plumbing, not surfaced. + flood_risk?: "high" | "medium" | "low" | null; + // Heritage listing. TWO-STATE: a status is set ONLY when a listing was detected on/around a linked + // parcel; null/absent is never rendered as "not listed". heritage_assessed is plumbing, not surfaced. + heritage_status?: "listed" | "zone" | null; + // Landslide-hazard. TWO-STATE: a category is set ONLY when a linked parcel intersects a mapped + // landslide-hazard zone (official 1:10,000-scale maps); null/absent is never rendered as "safe". + // landslide_assessed is plumbing, not surfaced. + landslide_risk?: "landslide" | "threatened" | null; coordinates?: [number, number] | null; } @@ -55,33 +197,122 @@ function formatTransactionCore(f: FormattableFields): string { const loc = f.street ? [streetAddr, district].filter(Boolean).join(", ") : [district, f.building_number].filter(Boolean).join(" "); - if (loc && region) parts.push(`${loc} (${region})`); - else if (loc) parts.push(loc); + // Street derived by us (not from the deed) → neutral marker so the model doesn't quote it as + // registry-sourced. Shown only when a street is actually present (street = rcn ?? derived). + const streetApprox = f.street != null && (f.address_source === "approx_high" || f.address_source === "approx_low"); + const approxTag = streetApprox ? " [street approximate — derived, not from deed]" : ""; + if (loc && region) parts.push(`${loc} (${region})${approxTag}`); + else if (loc) parts.push(`${loc}${approxTag}`); // Metadata line const meta: string[] = []; meta.push(`Date: ${f.transaction_date}`); - meta.push(PROPERTY_TYPES[f.property_type] || `Type ${f.property_type}`); + // Property type is raw (from the registry) unless we derived/corrected it → neutral + // marker so the model doesn't quote a computed type as the registry's literal classification. + // 'reclassed' is the more specific case (registry recorded land, the deed is a residential unit). + let typeLabel = PROPERTY_TYPES[f.property_type] || `Type ${f.property_type}`; + if (f.property_type_reclassed) typeLabel += " [shown as a unit — registry recorded land, the deed is a residential unit]"; + else if (f.property_type_inferred) typeLabel += " [type inferred from transaction structure — not stated in the registry]"; + meta.push(typeLabel); meta.push(MARKET_TYPES[f.market_type] || `Market ${f.market_type}`); parts.push(meta.join(" | ")); // Price line const price: string[] = []; price.push(`Price: ${formatPLN(f.price_gross)}`); - if (f.usable_area_m2 != null) price.push(`Area: ${formatArea(f.usable_area_m2)}`); + if (f.usable_area_m2 != null) { + // A building-basis garage area is the whole garage building, not the parking spot \u2014 + // flag it so the model never reports it as the unit's area or back-computes a price/m\u00B2. + const areaNote = f.area_basis === "building" ? " [whole garage building, not the parking space]" : ""; + price.push(`Area: ${formatArea(f.usable_area_m2)}${areaNote}`); + } if (f.price_per_m2 != null) price.push(`Price/m\u00B2: ${formatPLN(f.price_per_m2)}`); - if (f.parcel_area != null && f.usable_area_m2 == null) price.push(`Parcel: ${formatArea(f.parcel_area)}`); + if (f.parcel_area != null && f.usable_area_m2 == null) { + // parcel_area is raw unless we computed it: summed across plots (parcel_count >= 2) + // and/or converted from hectares the county reports in ha. Neutral marker(s), combined if both. + const pNotes: string[] = []; + if (f.parcel_count != null && f.parcel_count >= 2) pNotes.push(`sum of ${f.parcel_count} parcels`); + if (f.area_is_ha_converted) pNotes.push("converted from hectares — county reports area in ha"); + const pTag = pNotes.length > 0 ? ` [${pNotes.join("; ")}]` : ""; + price.push(`Parcel: ${formatArea(f.parcel_area)}${pTag}`); + } + // Fractional-share flag: neutral signal that this price reflects a partial + // ownership share (share \u2260 whole), so it is excluded from the market median. NOT "sale of a + // share" \u2014 that would be false for some new-build co-ownership (1/10 of common areas). + if (f.share_basis === "fraction") price.push("fractional share (excluded from market median)"); parts.push(price.join(" | ")); + // Building attrs. Footprint + storeys + estimated total floor area, when present. + // Gate on building_count FIRST (NULL = no buildings, not 0). Storeys is given only for + // single-building transactions. Neutral wording — no source register named. + if (f.building_count != null) { + const bld: string[] = []; + if (f.footprint_area_m2 != null) bld.push(`Building footprint: ${formatArea(f.footprint_area_m2)}`); + if (f.building_storeys != null) bld.push(`Storeys: ${f.building_storeys}`); + if (f.est_total_area_m2 != null) { + bld.push(`Est. total floor area: ${formatArea(f.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`); + } + if (bld.length > 0) parts.push(bld.join(" | ")); + } + + // Flood-hazard. TWO-STATE: surface a risk line ONLY when flood_risk is set (a linked parcel + // sits in a mapped hazard zone). Absence → no line at all; we never render an affirmative "no flood + // risk" (absence of a mapped zone is not evidence of safety). Detail via get_transaction_flood(id). + if (f.flood_risk) { + const note = FLOOD_RISK_NOTE[f.flood_risk]; + parts.push(`Flood risk: ${f.flood_risk}${note ? ` [mapped flood-hazard zone — ${note}]` : ""}`); + } + + // Heritage listing. TWO-STATE: surface a heritage line ONLY when heritage_status is set (a listing + // was detected on/around a linked parcel). Absence → no line at all; we never render an affirmative + // "not listed" (absence of a detection is not evidence there is no listing). The status is a floor — + // a listed property may also sit inside a protected zone. Detail via get_transaction_heritage(id). + if (f.heritage_status) { + const note = HERITAGE_STATUS_NOTE[f.heritage_status]; + parts.push(`Heritage listing: ${f.heritage_status}${note ? ` [${note}]` : ""}`); + } + // Landslide-hazard. TWO-STATE: surface a risk line ONLY when landslide_risk is set (a linked parcel + // intersects a mapped hazard area on the official 1:10,000-scale maps). Absence → no line at all; we + // never render an affirmative "no landslide risk" (absence of mapped data is not evidence of safety). + // Detail via get_transaction_landslide(id). + if (f.landslide_risk) { + const note = LANDSLIDE_RISK_NOTE[f.landslide_risk]; + parts.push(`Landslide risk: ${f.landslide_risk}${note ? ` [${note} — parcel intersects a mapped hazard area, 1:10,000-scale maps]` : ""}`); + } + // Extra details const extra: string[] = []; if (f.parcel_number) extra.push(`Plot no: ${f.parcel_number}`); if (f.rooms != null) extra.push(`Rooms: ${f.rooms}`); if (f.floor != null) extra.push(`Floor: ${f.floor}`); + // ── Raw deed fields — straight from the notarial deed, no provenance marker. + // Gated to suppress noise/NULLs and mirror the web drawer. + if (f.ownership_type != null) extra.push(`Ownership: ${OWNERSHIP_TYPES[f.ownership_type] || `Type ${f.ownership_type}`}`); + // ownership_share adds the share magnitude over share_basis — only meaningful for fractional rows + // (gate on share_basis, NOT on parsing "1/2"; a "1/1" full share would be noise). + if (f.share_basis === "fraction" && f.ownership_share) extra.push(`Share: ${f.ownership_share}`); + if (f.seller_type != null) extra.push(`Seller: ${PARTY_TYPES[f.seller_type] || `Party type ${f.seller_type}`}`); + if (f.buyer_type != null) extra.push(`Buyer: ${PARTY_TYPES[f.buyer_type] || `Party type ${f.buyer_type}`}`); + if (f.land_use) extra.push(`Land use: ${LAND_USES[f.land_use] || f.land_use}`); + // unit_price = deed price of the unit alone (PLN, never per-m²). Show only for units, only when > 0 + // and different from the total price — otherwise it duplicates price_gross or misleads for land/buildings + // (mirror the web drawer). NUMERIC arrives as string → coerce. + if (f.property_type === 4 && f.unit_price != null && Number(f.unit_price) > 0 && Number(f.unit_price) !== Number(f.price_gross)) { + extra.push(`Deed unit price (not per-m²): ${formatPLN(Number(f.unit_price))}`); + } + // vat = raw RCN field: may be a rate (%) OR an amount (zł), as recorded — no unit appended (the column + // mixes both; guessing would mislead). NUMERIC → string. Omit when NULL/empty/non-numeric. + if (f.vat != null && f.vat !== "") { + const vatNum = Number(f.vat); + if (Number.isFinite(vatNum)) extra.push(`VAT (as recorded — rate % or amount): ${formatNumber(vatNum)}`); + } if (f.coordinates) { const [lng, lat] = f.coordinates; extra.push(`Location: ${lat?.toFixed(4)}\u00B0N, ${lng?.toFixed(4)}\u00B0E`); } + // Transaction id, last: unconditional now \u2014 feeds get_building_breakdown(id) AND the + // cenogram.pl deep link (#...&tx=). Compact, after the human-readable fields. + if (f.id) extra.push(`id: ${f.id}`); if (extra.length > 0) parts.push(extra.join(" | ")); return parts.join("\n "); @@ -121,6 +352,23 @@ export function formatTransactionList( if (parts.length > 0) lines.push(`\nSummary: ${parts.join(" | ")}`); } + // Cross-link to get_building_breakdown when at least one row has buildings (id is surfaced inline). + if (data.some((tx) => tx.building_count != null)) { + lines.push(`\n${BUILDING_BREAKDOWN_TIP}`); + } + // Cross-link to get_transaction_flood when at least one row sits in a mapped flood zone. + if (data.some((tx) => tx.flood_risk != null)) { + lines.push(`\n${FLOOD_BREAKDOWN_TIP}`); + } + // Cross-link to get_transaction_heritage when at least one row has a detected heritage listing. + if (data.some((tx) => tx.heritage_status != null)) { + lines.push(`\n${HERITAGE_BREAKDOWN_TIP}`); + } + // Cross-link to get_transaction_landslide when at least one row intersects a mapped landslide zone. + if (data.some((tx) => tx.landslide_risk != null)) { + lines.push(`\n${LANDSLIDE_BREAKDOWN_TIP}`); + } + return lines.join("\n"); } @@ -159,6 +407,8 @@ export function formatMarketOverview(stats: StatsResponse): string { }); } + lines.push(`\n${MARKET_CAVEAT}`); + return lines.join("\n"); } @@ -195,6 +445,8 @@ export function formatPriceStats( lines.push(`\n...and ${sorted.length - 30} more locations.`); } + lines.push(`\n${MARKET_CAVEAT}`); + return lines.join("\n"); } @@ -215,6 +467,8 @@ export function formatHistogram(bins: HistogramBin[]): string { ); } + lines.push(`\n${MARKET_CAVEAT}`); + return lines.join("\n"); } @@ -230,12 +484,40 @@ export function formatParcelResults(res: ParcelSearchResponse, query: string): s const district = p.district ?? "Unknown"; const area = p.area_m2 != null ? formatArea(p.area_m2) : "N/A"; const location = `${p.lat.toFixed(4)}\u00B0N, ${p.lng.toFixed(4)}\u00B0E`; - lines.push(`${i + 1}. ${p.parcel_id}`); + // parcel_id is gated identity. MCP token callers always receive it; guard anyway so a + // stripped response never renders the literal "undefined". + lines.push(`${i + 1}. ${p.parcel_id ?? "(parcel number requires a paid plan)"}`); lines.push(` District: ${district} | Area: ${area} | Location: ${location}`); } return lines.join("\n"); } +// ── Parcel resolve formatting ────────────────────────────────────── + +export function formatParcelResolve(res: ParcelResolveResponse): string { + if (res.coverage === "not_covered" || res.matches.length === 0) { + return "No parcel matched. The identifier or 'name + number' is not in our cadastral copy (the credit is refunded). Check the spelling of the locality name, or use search_parcels to look up a parcel id by prefix."; + } + + const lines: string[] = [`Found ${res.matches.length} parcel${res.matches.length === 1 ? "" : "s"}:\n`]; + for (const [i, m] of res.matches.entries()) { + // parcel_id may be null per the server contract; guard so a null identity never renders literally. + const id = m.parcel_id ?? "(parcel id requires a paid plan)"; + const district = m.district ?? "Unknown"; + const area = m.area_m2 != null ? formatArea(m.area_m2) : "N/A"; + const location = m.centroid ? `${m.centroid.lat.toFixed(4)}°N, ${m.centroid.lng.toFixed(4)}°E` : "no geometry"; + lines.push(`${i + 1}. ${id}`); + lines.push(` District: ${district} | Area: ${area} | Location: ${location}`); + } + if (res.truncated) { + lines.push(`\nMore matches exist than shown — narrow the locality name or provide the full parcel id.`); + } + if (res.as_of) { + lines.push(`\nCadastral copy as of ${res.as_of.split("T")[0]}.`); + } + return lines.join("\n"); +} + // ── Spatial search formatting ────────────────────────────────────── function formatSpatialFeature(f: SpatialFeature): string { @@ -268,6 +550,546 @@ export function formatSpatialResults(res: SpatialSearchResponse): string { lines.push(`\n...and ${res.features.length - displayCap} more in response (not displayed). Use a smaller limit or narrower polygon.`); } + // Same cross-link as the list formatter, so polygon/area callers also discover get_building_breakdown. + if (res.features.some((f) => f.properties.building_count != null)) { + lines.push(`\n${BUILDING_BREAKDOWN_TIP}`); + } + + return lines.join("\n"); +} + +// ── Building breakdown formatting (per-transaction, per-building) ─── + +export function formatBuildingBreakdown(res: BuildingBreakdownResponse): string { + const { data, truncated } = res; + // Empty data covers both "transaction has no buildings" and "unknown/garbage id" (REST returns + // 200 + [] for both) — a single neutral message fits both without leaking which case it was. + if (data.length === 0) { + return "No per-building data available for this transaction."; + } + + const lines: string[] = [`Per-building breakdown (${data.length} building${data.length === 1 ? "" : "s"}):`, ""]; + + data.forEach((b, i) => { + const cells: string[] = []; + + const typeLabel = b.building_type != null + ? (BUILDING_TYPES[b.building_type] ?? `Type ${b.building_type}`) + : "Building"; + cells.push(typeLabel); + + if (b.footprint_area_m2 != null) { + // Surface the second measurement only when the two diverge (>10%) — a neutral "two independent + // measurements disagree" signal, no source register named. When they agree, the canonical + // footprint already represents both, so the alt is noise. + const alt = b.footprint_divergent === true && b.footprint_area_alt_m2 != null + ? ` (alt. measurement ${formatArea(b.footprint_area_alt_m2)} — diverge)` + : ""; + cells.push(`footprint ${formatArea(b.footprint_area_m2)}${alt}`); + } + + if (b.storeys != null) cells.push(`storeys ${b.storeys}`); + + if (b.est_total_area_m2 != null) { + cells.push(`est. total floor area ${formatArea(b.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`); + } + + // match_confidence is a readable enum (high/low) or null — render verbatim when present. + if (b.match_confidence) cells.push(`match confidence: ${b.match_confidence}`); + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 buildings (the transaction has more)."); + } + + return lines.join("\n"); +} + +// ── Flood-zone breakdown formatting (per-transaction, per-parcel) ─── + +export function formatFloodBreakdown(res: FloodBreakdownResponse): string { + const { data, truncated } = res; + // TWO-STATE: empty covers both "no linked parcel sits in a mapped zone" and "unknown/garbage id" (REST + // returns 200 + [] for both). We NEVER assert "no flood risk" — absence of a mapped zone is not evidence + // of safety. One neutral message fits both without leaking which case it was. + if (data.length === 0) { + return "No mapped flood-hazard zone is recorded for this transaction's land (or the id was not found). Absence of a mapped zone is not a guarantee of safety — it is never asserted as 'no risk'."; + } + + const lines: string[] = [ + `Per-parcel flood-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} in a mapped flood-hazard zone):`, + "", + ]; + + data.forEach((r, i) => { + const cells: string[] = []; + + const note = r.flood_risk ? FLOOD_RISK_NOTE[r.flood_risk] : undefined; + cells.push(`risk: ${r.flood_risk ?? "—"}${note ? ` (${note})` : ""}`); + + if (r.source) cells.push(`source: ${r.source}`); + + // pct_in_zone = share of the parcel inside the worst-scenario zone (NUMERIC → string over the wire). + if (r.pct_in_zone != null) { + const pct = Number(r.pct_in_zone); + if (Number.isFinite(pct)) cells.push(`${Math.round(pct)}% of the parcel in the worst-scenario zone`); + } + + // scenarios = bounded list (≤7) of distinct hazard scenarios for this parcel. Only the readable + // labels are rendered; the numeric fields alongside them carry no meaningful value. + if (Array.isArray(r.scenarios) && r.scenarios.length > 0) { + const labels = r.scenarios.map((s) => s.scenario).filter((s): s is string => !!s); + if (labels.length > 0) cells.push(`scenarios: ${labels.join("; ")}`); + } + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 parcels (the transaction is linked to more)."); + } + + return lines.join("\n"); +} + +// ── Heritage-listing breakdown formatting (per-transaction, per-parcel) ── + +export function formatHeritageBreakdown(res: HeritageBreakdownResponse): string { + const { data, truncated } = res; + // TWO-STATE: empty covers both "no listing detected for any linked parcel" and "unknown/garbage id" + // (REST returns 200 + [] for both). We NEVER assert "not a listed monument" — absence of a detection + // is not evidence there is no listing. One neutral message fits both without leaking which case it was. + if (data.length === 0) { + return "No heritage-listing records found for this transaction's parcels (or the id was not found). This is not a statement that the property is free of heritage protection — absence of a detection is never asserted as 'not listed'."; + } + + const lines: string[] = [ + `Per-parcel heritage-listing breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} with a detected listing):`, + "", + ]; + + data.forEach((r, i) => { + const cells: string[] = []; + + const note = r.heritage_status ? HERITAGE_STATUS_NOTE[r.heritage_status] : undefined; + cells.push(`status: ${r.heritage_status ?? "—"}${note ? ` (${note})` : ""}`); + + if (r.site_count != null) cells.push(`entries: ${r.site_count}`); + + // pct_in_zone = share of the parcel inside the protected area (NUMERIC → string over the wire). + // Null when only point/line-located entries matched — nothing areal to measure coverage against. + if (r.pct_in_zone != null) { + const pct = Number(r.pct_in_zone); + if (Number.isFinite(pct)) cells.push(`${Math.round(pct)}% of the parcel in the protected area`); + } + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + + // Individual entries, one indented line each. Entries carry more fields than fit a single cell + // (category, name, function, period, entry date) — sub-lines keep a multi-entry parcel readable. + // entry_date may arrive as a full ISO timestamp — keep the date part only. + if (Array.isArray(r.sites)) { + for (const s of r.sites) { + const detail: string[] = [s.category]; + if (s.name) detail.push(s.name); + if (s.function) detail.push(`function: ${s.function}`); + if (s.period) detail.push(`period: ${s.period}`); + if (s.entry_date) detail.push(`entered: ${s.entry_date.split("T")[0]}`); + lines.push(` - ${detail.join(" | ")}`); + } + } + }); + + if (truncated) { + lines.push("", "Showing the first 500 parcels (the transaction is linked to more)."); + } + + lines.push("", HERITAGE_DISCLAIMER); + + return lines.join("\n"); +} + +// ── Landslide-zone breakdown formatting (per-transaction, per-parcel) ─── + +export function formatLandslideBreakdown(res: LandslideBreakdownResponse): string { + const { data, truncated } = res; + // TWO-STATE: empty covers both "no linked parcel intersects a mapped zone" and "unknown/garbage id" + // (REST returns 200 + [] for both). We NEVER assert "no landslide risk" — absence of mapped data is + // not evidence of safety. One neutral message fits both without leaking which case it was. + if (data.length === 0) { + return "No mapped landslide-hazard zone intersects this transaction's parcels (or the id was not found). Absence of mapped data is not a guarantee of safety — it is never asserted as 'no risk'."; + } + + const lines: string[] = [ + `Per-parcel landslide-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} intersecting a mapped landslide-hazard zone):`, + "", + ]; + + data.forEach((r, i) => { + const cells: string[] = []; + + const note = r.landslide_risk ? LANDSLIDE_RISK_NOTE[r.landslide_risk] : undefined; + cells.push(`risk: ${r.landslide_risk ?? "—"}${note ? ` (${note})` : ""}`); + + // pct_in_zone = share of the parcel inside the mapped zones (NUMERIC → string over the wire). + if (r.pct_in_zone != null) { + const pct = Number(r.pct_in_zone); + if (Number.isFinite(pct)) cells.push(`${Math.round(pct)}% of the parcel in mapped zones`); + } + + // zones = bounded list of distinct mapped-hazard records for this parcel (two kinds exist, deduped + // per kind + version date). source_version_date = the source-record version date, NOT a + // survey/observation date — label it as such so the model never quotes it as "surveyed on". + if (Array.isArray(r.zones) && r.zones.length > 0) { + const labels = r.zones + .map((z) => { + if (!z.kind) return null; + return z.source_version_date ? `${z.kind} (record version date: ${z.source_version_date})` : z.kind; + }) + .filter((s): s is string => !!s); + if (labels.length > 0) cells.push(`zones: ${labels.join("; ")}`); + } + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 parcels (the transaction is linked to more)."); + } + + // Interpretation guard: intersection at map scale 1:10,000 = the parcel overlaps a mapped hazard + // area — NOT a statement that the parcel itself is a landslide. + lines.push("", "Note: based on official landslide-hazard maps (1:10,000 scale). An intersection means the parcel overlaps a mapped hazard area, not that the parcel itself is a landslide."); + + return lines.join("\n"); +} + +// ── Surroundings formatting (per-transaction, per-parcel) ────────── + +// Nuisance categories with the fixed per-category search radius (meters) used by the assessment. +// A null distance means nothing of that category was found within this radius — it is NOT a claim +// that none exists farther away (two-state semantics, like flood). +const SURROUNDINGS_CATEGORIES: { key: keyof SurroundingsRow; label: string; radiusLabel: string }[] = [ + { key: "cemetery_distance_m", label: "cemetery", radiusLabel: "1 km" }, + { key: "landfill_distance_m", label: "landfill (waste disposal)", radiusLabel: "3 km" }, + { key: "sewage_treatment_distance_m", label: "sewage treatment plant", radiusLabel: "2 km" }, + { key: "industrial_area_distance_m", label: "industrial/storage area", radiusLabel: "1 km" }, + { key: "industrial_plant_distance_m", label: "large industrial plant", radiusLabel: "3 km" }, + { key: "livestock_farm_distance_m", label: "intensive livestock farm", radiusLabel: "3 km" }, +]; + +export function formatSurroundings(res: SurroundingsResponse): string { + const { data, truncated } = res; + // TWO-STATE: empty covers both "the transaction has no linked plots" and "unknown/garbage id" + // (REST returns 200 + [] for both). One neutral message fits both without leaking which case it was. + if (data.length === 0) { + return "No surroundings data is available for this transaction (no linked plots, or the id was not found)."; + } + + const lines: string[] = [ + `Per-parcel surroundings (${data.length} plot${data.length === 1 ? "" : "s"}; distance from the plot boundary to the nearest mapped object, "~" = approximate):`, + "", + ]; + + data.forEach((r, i) => { + if (!r.assessed) { + lines.push(`${i + 1}. not assessed yet — this plot has not been evaluated (no statement either way)`); + return; + } + + const cells = SURROUNDINGS_CATEGORIES.map(({ key, label, radiusLabel }) => { + const raw = r[key]; + const dist = raw == null ? null : Number(raw); + if (dist == null || !Number.isFinite(dist)) { + // Absence within the search radius — never rendered as "none exists". + return `${label}: none within ${radiusLabel}`; + } + if (dist === 0) return `${label}: on or adjoining the plot`; + return `${label}: ~${Math.round(dist)} m`; + }); + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 plots (the transaction is linked to more)."); + } + + return lines.join("\n"); +} + +// ── Public transport access breakdown formatting (per-transaction, per-parcel) ── + +// Reminder that a missing mode in the breakdown below is a coverage gap, not a fact about the world — open +// GTFS feeds cover cities and national rail, not every rural area or bus-only route. Shown once per +// response so it isn't lost among the per-parcel lines. +const TRANSIT_COVERAGE_NOTE = + "Note: distances are from open public-transport schedules (GTFS format); coverage is cities and national rail, not every rural area. A mode missing above means no stop of that mode was found within its distance cap — never read as 'no public transport access'."; + +export function formatTransitBreakdown(res: TransitBreakdownResponse): string { + const { data, truncated } = res; + // TWO-STATE: empty covers both "no linked parcel has a stop within cap in any mode" and "unknown/garbage + // id" (REST returns 200 + [] for both). We NEVER assert "no transit access" — open GTFS feeds don't + // cover every rural area or bus-only route. One neutral message fits both without leaking which case it was. + if (data.length === 0) { + return `No public transport stop is recorded near this transaction's land in any mode (or the id was not found). ${TRANSIT_COVERAGE_NOTE}`; + } + + const lines: string[] = [ + `Per-parcel public transport access (${data.length} parcel${data.length === 1 ? "" : "s"} with a stop nearby, from open GTFS data):`, + "", + ]; + + // Invariant: every row has ≥1 non-null mode (enforced upstream by a data-layer CHECK constraint and + // the endpoint's two-state filter), so `cells` is never empty here — no bare "N. " line can be emitted. + data.forEach((r, i) => { + const cells: string[] = []; + if (r.rail_distance_m != null) cells.push(`Rail: ${r.rail_distance_m} m${r.rail_stop_name ? ` (${r.rail_stop_name})` : ""}`); + if (r.metro_distance_m != null) cells.push(`Metro: ${r.metro_distance_m} m${r.metro_stop_name ? ` (${r.metro_stop_name})` : ""}`); + if (r.tram_distance_m != null) cells.push(`Tram: ${r.tram_distance_m} m${r.tram_stop_name ? ` (${r.tram_stop_name})` : ""}`); + if (r.bus_distance_m != null) cells.push(`Bus: ${r.bus_distance_m} m${r.bus_stop_name ? ` (${r.bus_stop_name})` : ""}`); + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 parcels (the transaction is linked to more)."); + } + + lines.push("", TRANSIT_COVERAGE_NOTE); + + return lines.join("\n"); +} + +// ── Building-permit breakdown formatting (per-transaction, per-parcel) ── + +// Neutral disclaimer for an empty permits result. Identity-safe (no source-registry name) and +// TWO-STATE: an empty list is never rendered as "nothing was ever planned". Covers both +// "no registered case for any linked parcel" and "unknown/garbage id" (REST returns 200 + [] +// for both) — one message fits both without leaking which case it was. +const PERMITS_EMPTY_NOTE = + "No positively-resolved building permit or works notification is on record for this transaction's parcels (or the id was not found). The register covers cases resolved since 2016, matched by the parcel's current identifier — an empty list is never a statement that nothing was ever planned."; + +export function formatPermitsBreakdown(res: PermitsResponse): string { + const { data, truncated } = res; + if (data.length === 0) { + return PERMITS_EMPTY_NOTE; + } + + const lines: string[] = [ + `Building permits & notifications on record for this transaction's parcels (${data.length} record${data.length === 1 ? "" : "s"}):`, + "", + ]; + + data.forEach((r, i) => { + const cells: string[] = []; + cells.push(r.record_kind); + if (r.intent_type) cells.push(`intent: ${r.intent_type}`); + if (r.works_type) cells.push(`works: ${r.works_type}`); + if (r.object_category) cells.push(`category: ${r.object_category}`); + if (r.status) cells.push(`status: ${r.status}`); + // Prefer the decision date (permits); fall back to the intake date (notifications have no + // decision date). Both are YYYY-MM-DD strings. + const date = r.decision_date ?? r.intake_date; + if (date) cells.push(`date: ${date}`); + if (r.authority) cells.push(`authority: ${r.authority}`); + // Investment address (street / number / city) — administrative fact, no parcel identity. + const addr = [r.address_street, r.address_number].filter(Boolean).join(" "); + const addrFull = [addr, r.address_city].filter(Boolean).join(", "); + if (addrFull) cells.push(`address: ${addrFull}`); + if (r.volume_m3 != null && Number.isFinite(Number(r.volume_m3))) { + cells.push(`volume: ${Math.round(Number(r.volume_m3))} m³`); + } + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 records (the transaction's parcels have more)."); + } + + return lines.join("\n"); +} + +// ── General-plan (POG) planning-zone breakdown formatting ────────── + +// Overlay kinds → readable label. Overlays sit on top of base zones (their coverage is independent of +// the base-zone shares), so they are rendered as their own lines. Only the two lawful overlay kinds +// exist; an unknown kind falls back to a neutral label. +const PLANNING_OVERLAY_LABEL: Record = { + infill_area: "Infill development area (obszar uzupełnienia zabudowy)", + downtown_area: "Central development area (obszar zabudowy śródmiejskiej)", +}; + +// Coerce a wire value (number or NUMERIC-as-string) to a finite number, or null. Mirrors the flood / +// heritage row contract where NUMERIC columns can arrive as strings. +function planningNum(raw: number | string | null | undefined): number | null { + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +// Render a numeric building parameter at its source precision. Never round: an intensity of 1.25 is a +// binding limit, and 1.3 would be a different one. String(n) already drops a trailing ".0". +function planningParam(raw: number | string | null | undefined, unit: string): string | null { + const n = planningNum(raw); + if (n == null) return null; + return `${String(n)}${unit}`; +} + +export function formatPlanningBreakdown(res: PlanningResponse): string { + const { data, coverage, truncated } = res; + + // THREE-STATE (not the two-state hazard pattern). Empty data splits into two honest cases by + // `coverage`, and NEITHER ever asserts the municipality has no general plan. + if (data.length === 0) { + if (coverage === "covered_no_data") { + return "This transaction's municipality has an adopted general plan (plan ogólny), but no planning-zone data covers these parcels in our sources yet."; + } + // 'not_covered' (also the fallback for an unknown/garbage id, which returns 200 + empty). + return "No published general plan (plan ogólny) data is available for this transaction's municipality yet — this is NOT a statement that no plan exists. General plans are still being adopted across Poland, so coverage grows over time."; + } + + const zones = data.filter((r) => r.kind === "zone"); + const overlays = data.filter((r) => r.kind !== "zone"); + + // Build the count phrase from whatever is actually present, so an overlay-only transaction (rare, but + // structurally possible at the parcel×kind grain) never reads "0 planning zones". + const counts: string[] = []; + if (zones.length > 0) counts.push(`${zones.length} planning zone${zones.length === 1 ? "" : "s"}`); + if (overlays.length > 0) counts.push(`${overlays.length} overlay area${overlays.length === 1 ? "" : "s"}`); + + const lines: string[] = [ + `General plan (plan ogólny) zoning for this transaction's land (${counts.join(", ")}):`, + "", + ]; + + // Group by parcel. A transaction spanning several parcels repeats a zone symbol once per parcel, and a + // flat list makes that look like a duplicate row — the model would then double-count the zone. + const byParcel = new Map(); + for (const r of data) { + const list = byParcel.get(r.parcel_ord); + if (list) list.push(r); + else byParcel.set(r.parcel_ord, [r]); + } + const multiParcel = byParcel.size > 1; + + let n = 0; + for (const [ord, prows] of byParcel) { + if (multiParcel) { + if (n > 0) lines.push(""); + lines.push(`Parcel ${ord} of ${byParcel.size}:`); + } + + // Base planning zones first: symbol + name + share + building parameters (each nullable). + for (const r of prows.filter((x) => x.kind === "zone")) { + n += 1; + const cells: string[] = []; + + const label = r.zone_symbol + ? `${r.zone_symbol}${r.zone_name ? ` — ${r.zone_name}` : ""}` + : r.zone_name ?? "planning zone"; + cells.push(label); + + const pct = planningNum(r.pct_of_parcel); + if (pct != null) cells.push(`${Math.round(pct)}% of the parcel`); + + const params: string[] = []; + const height = planningParam(r.max_building_height_m, " m"); + if (height) params.push(`max building height: ${height}`); + const intensity = planningParam(r.max_development_intensity, ""); + if (intensity) params.push(`max development intensity: ${intensity}`); + const coveragePct = planningParam(r.max_built_up_coverage_pct, "%"); + if (coveragePct) params.push(`max built-up coverage: ${coveragePct}`); + const bioPct = planningParam(r.min_bio_active_area_pct, "%"); + if (bioPct) params.push(`min biologically active area: ${bioPct}`); + if (params.length > 0) cells.push(params.join(", ")); + + lines.push(`${n}. ${cells.join(" | ")}`); + + // params_mixed: this symbol merges sub-zones whose parameters disagreed — the ambiguous ones are + // reported as null above (never guessed). Flag it so the model does not read a missing parameter + // as "no limit". + if (r.params_mixed) { + lines.push(" - note: this symbol merges sub-zones with differing building parameters; only values that agreed across them are shown, the rest are omitted as ambiguous (not 'no limit')."); + } + } + + // Overlay areas as their own lines — their coverage is independent of (and may overlap) base zones. + for (const r of prows.filter((x) => x.kind !== "zone")) { + n += 1; + const label = PLANNING_OVERLAY_LABEL[r.kind] ?? "development overlay area"; + const pct = planningNum(r.pct_of_parcel); + lines.push(`${n}. ${label} — overlay${pct != null ? `, ${Math.round(pct)}% of the parcel` : ""}`); + } + } + + if (multiParcel) { + lines.push("", "Note: this transaction covers several land parcels. Zones are listed per parcel, so the same symbol may appear under more than one parcel — that is not a duplicate."); + } + + if (truncated) { + lines.push("", "Showing the first 500 rows (this transaction's land carries more zones/overlays)."); + } + + // Interpretation guard: shares are measured against the cadastral parcel geometry, and overlay shares + // are independent of base-zone shares (overlays may overlap zones), so the percentages need not sum to + // 100. Authored here (not echoed from the response) so the wording is deterministic and testable. + lines.push("", "Note: shares are relative to the cadastral parcel geometry; base-zone and overlay shares are independent (overlays may sit on top of zones), so they need not add up to 100%."); + + return lines.join("\n"); +} + +// ── Farmland (agricultural land-eligibility) formatting (per-transaction, per-parcel) ── + +export function formatFarmland(res: FarmlandResponse): string { + const { data, truncated, parcels_total, parcels_with_data, as_of } = res; + // TWO-STATE: empty covers both "no linked parcel has a matched eligible area" and "unknown/garbage id" + // (REST returns 200 + [] for both). We NEVER assert "not agricultural" — the reference layer has its + // own update cadence, small plots that are not actively farmed are simply absent, and older + // transactions can reference renumbered parcels. One neutral message fits both. + if (data.length === 0) { + const asOfNote = as_of ? ` (reference data as of ${as_of})` : ""; + return `No eligible agricultural area found for the linked parcels (or the id was not found)${asOfNote}. This is not a statement that the property is non-agricultural — absence of a match is never asserted as "not agricultural".`; + } + + const lines: string[] = [ + // Header carries the coverage counters: how many of the transaction's linked parcels carry a match. + `Per-parcel agricultural land-eligibility (${parcels_with_data} of ${parcels_total} linked parcel${parcels_total === 1 ? "" : "s"} with a matched eligible area):`, + "", + ]; + + data.forEach((r, i) => { + const cells: string[] = []; + + // eligible_area_m2 = the eligible agricultural area matched onto this parcel. + cells.push(`eligible agricultural area: ${formatArea(r.eligible_area_m2)}`); + + // pct_of_parcel = that area as a share of the parcel's measured area. Null when the parcel's measured + // area is unavailable — omit the cell rather than render a misleading value. + if (r.pct_of_parcel != null) { + const pct = Number(r.pct_of_parcel); + if (Number.isFinite(pct)) cells.push(`${Math.round(pct)}% of the parcel`); + } + + // feature_count = number of source features composing the matched area (surface only when >1 adds info). + if (r.feature_count != null && Number(r.feature_count) > 1) { + cells.push(`${Number(r.feature_count)} features`); + } + + lines.push(`${i + 1}. ${cells.join(" | ")}`); + }); + + if (truncated) { + lines.push("", "Showing the first 500 parcels (the transaction is linked to more)."); + } + + // Freshness signal — the reference layer is refreshed on its own cadence; expose the snapshot date so + // the model can frame the answer against it rather than treating it as current-day ground truth. + if (as_of) { + lines.push("", `Official nationwide agricultural land-eligibility data (updated weekly); this snapshot as of ${as_of}.`); + } + return lines.join("\n"); } @@ -345,5 +1167,668 @@ export function formatCompareResults(res: CompareResponse): string { } } + // Demographics enrichment (?include=demographics → includeDemographics=true). REST OMITS the + // whole `demographics` key for any district it couldn't resolve to a county, so tolerate its + // absence: render only the districts that carry it, then one footnote for the rest. + const withDemo = districts.filter((name) => { + const d = res[name]?.demographics; + return d && Object.keys(d).length > 0; + }); + if (withDemo.length > 0) { + lines.push("", "Demographics (GUS BDL, county-level):"); + for (const name of withDemo) { + lines.push("", `${name}:`); + for (const [slug, ind] of Object.entries(res[name]!.demographics!)) { + const unit = ind.unit ? ` ${ind.unit}` : ""; + const year = ind.year != null ? ` (${ind.year})` : ""; + const flags = [ind.derived ? "derived" : null, ind.cross_source ? "cross-source" : null].filter(Boolean); + const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; + const value = ind.value != null ? `${formatNumber(ind.value)}${unit}` : "N/A"; + lines.push(` - ${slug}: ${value}${year}${flagSuffix}`); + } + } + const missing = districts.filter((name) => !withDemo.includes(name)); + if (missing.length > 0) { + lines.push("", `Note: no demographic data for ${missing.join(", ")} (not resolved to a county).`); + } + } + + lines.push(`\n${MARKET_CAVEAT}`); + + return lines.join("\n"); +} + +// ── Demographics formatting (GUS BDL) ────────────────────────────── + +// category key → readable section header. Order also sets the section order in the output. +const DEMOGRAPHICS_CATEGORY_LABELS: Record = { + demographics: "Demographics", + economy: "Economy", + economy_macro: "Macro-economy (GDP, NUTS3/region)", + housing: "Housing", + planning: "Spatial planning (MPZP zoning)", + infrastructure: "Infrastructure", + environment: "Environment", + safety: "Safety", + re_market: "Real estate market (historical)", + education: "Education", + prices: "Prices (CPI)", +}; +const DEMOGRAPHICS_CATEGORY_ORDER = Object.keys(DEMOGRAPHICS_CATEGORY_LABELS); + +// One indicator → " - Name: value unit (year) [flags]". Compacts a long time series so ~50 +// indicators stay scannable: single year → value+year; ≤5 years → inline series; >5 → latest + +// span summary. Surfaces derived/snapshot flags and any data-quality note. +function formatDemographicsIndicator(ind: DemographicsIndicator): string { + const years = Object.keys(ind.values).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b); + const unit = ind.unit ? ` ${ind.unit}` : ""; + const flags = [ind.derived ? "derived" : null, ind.snapshot ? "snapshot" : null].filter(Boolean); + const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; + const noteSuffix = ind.note ? ` — ${ind.note}` : ""; + + let valueStr: string; + if (years.length === 0) { + valueStr = "N/A"; + } else if (years.length === 1) { + const y = years[0]!; + valueStr = `${formatNumber(ind.values[String(y)]!)}${unit} (${y})`; + } else if (years.length <= 5) { + valueStr = years.map((y) => `${y}: ${formatNumber(ind.values[String(y)]!)}`).join(", ") + unit; + } else { + const first = years[0]!; + const last = years[years.length - 1]!; + valueStr = `${formatNumber(ind.values[String(last)]!)}${unit} (${last}); ${years.length} yrs ${first}→${last}, from ${formatNumber(ind.values[String(first)]!)}`; + } + return ` - ${ind.name}: ${valueStr}${flagSuffix}${noteSuffix}`; +} + +export function formatDemographics(r: DemographicsResponse): string { + const loc = r.location; + const title = loc.name ?? `TERYT ${loc.teryt}`; + const lines: string[] = [`Demographics & local statistics — ${title} (${loc.level}, teryt ${loc.teryt})`]; + const asOf = r.meta.as_of ? ` · as of ${r.meta.as_of}` : ""; + lines.push(`Source: ${r.meta.data_source}${asOf}`); + + if (r.coverage === "no_data" || Object.keys(r.indicators).length === 0) { + lines.push( + "", + `No GUS BDL indicators are available for this location (teryt ${loc.teryt}).`, + "Tip: a city/county name resolves to powiat (county) level — pass a 6/7-digit teryt for gmina-level data, or use list_locations to find a valid code.", + ); + return lines.join("\n"); + } + + // Group indicators by category, then emit in canonical order (unknown categories last). + const byCategory = new Map(); + for (const ind of Object.values(r.indicators)) { + const arr = byCategory.get(ind.category) ?? []; + arr.push(ind); + byCategory.set(ind.category, arr); + } + const orderedCats = [ + ...DEMOGRAPHICS_CATEGORY_ORDER.filter((c) => byCategory.has(c)), + ...[...byCategory.keys()].filter((c) => !DEMOGRAPHICS_CATEGORY_ORDER.includes(c)), + ]; + for (const cat of orderedCats) { + lines.push("", DEMOGRAPHICS_CATEGORY_LABELS[cat] ?? cat); + for (const ind of byCategory.get(cat)!) lines.push(formatDemographicsIndicator(ind)); + } + + // A gmina/powiat query returns parent-level rows too — flag when indicators span multiple levels + // so the model reads each line's level rather than assuming all are the requested level. + const levels = [...new Set(Object.values(r.indicators).map((i) => i.level))]; + if (levels.length > 1) { + lines.push("", `Note: indicators draw from multiple administrative levels (${levels.join(", ")}); each line's level is where GUS publishes that metric.`); + } + return lines.join("\n"); +} + +// ── Infrastructure signals formatting ────────────────────────────── + +const INFRA_CATEGORY_LABELS: Record = { + sewerage: "Sewerage", + water_supply: "Water supply", + roads: "Roads", + lighting: "Street lighting", + gas: "Gas network", + cycling: "Cycling infrastructure", +}; + +// An estimate published before bidding is a very different number from a signed contract — never +// let the model read them as the same thing. +const INFRA_VALUE_KIND_LABELS: Record = { + estimated: "estimated value", + winning_bid: "winning bid", + contract: "contract value", +}; + +export function formatInfrastructureSignals(r: InfrastructureSignalsResponse): string { + const loc = r.location; + const title = loc.name ?? `TERYT ${loc.teryt}`; + const scope = loc.level === "powiat" ? "aggregated over every municipality in this county" : "this municipality"; + const lines: string[] = [ + `Infrastructure signals — ${title} (${loc.level}, teryt ${loc.teryt})`, + `Scope: ${scope}. Coverage: ${r.coverage}.`, + ]; + + // Dual-state: nothing found is NOT evidence that nothing is planned. Say so before any data. + if (r.coverage === "no_data") { + lines.push( + "", + "No infrastructure signals are recorded for this location.", + "This does NOT mean the municipality is not investing — the tender feed carries below-EU-threshold contracts only (from 2021), and the other two sources may simply not list it.", + r.meta.coverage_note, + ); + return lines.join("\n"); + } + + const cats = Object.entries(r.tenders.by_category).sort((a, b) => b[1] - a[1]); + lines.push("", `Public tenders, last ${r.tenders.window_months} months (municipal contracting authorities only)`); + if (cats.length === 0) lines.push(" None recorded in this window."); + else for (const [cat, n] of cats) lines.push(` - ${INFRA_CATEGORY_LABELS[cat] ?? cat}: ${n}`); + + if (r.tenders.recent.length > 0) { + lines.push("", "Recent notices (all contracting authorities)"); + for (const t of r.tenders.recent) { + const value = t.value_pln == null + ? "" + : ` · ${formatNumber(t.value_pln)} PLN (${INFRA_VALUE_KIND_LABELS[t.value_kind ?? ""] ?? t.value_kind ?? "value"})`; + // Flag anything the municipality did not tender itself — those works may sit elsewhere. + const attribution = t.attribution_confidence === "high" ? "" : " · authority based here, works may be elsewhere"; + lines.push(` - [${t.published_at}] ${INFRA_CATEGORY_LABELS[t.category] ?? t.category}: ${t.title}${value}${attribution}`); + } + if (r.tenders.truncated) lines.push(` … list truncated at ${r.tenders.recent.length} notices.`); + } + + lines.push("", "National urban waste-water treatment programme"); + if (r.kposk.in_agglomeration) { + lines.push(" In a designated agglomeration — collective sewerage exists or is planned here."); + for (const a of r.kposk.agglomerations) { + const rlm = a.rlm == null ? "" : ` (${formatNumber(a.rlm)} population equivalent)`; + lines.push(` - ${a.name}${rlm}`); + } + if (r.kposk.truncated) lines.push(` … list truncated at ${r.kposk.agglomerations.length} agglomerations.`); + } else { + lines.push(" Not listed in a designated agglomeration."); + } + + // Rendered even when empty, like the other two overlays — an omitted section reads as "not + // checked" rather than "checked, nothing there". + const years = Object.entries(r.capex.by_year).sort(([a], [b]) => a.localeCompare(b)); + lines.push("", "Planned capital expenditure (municipal multi-year financial forecast)"); + if (years.length === 0) lines.push(" No forecast rows recorded for this location."); + for (const [year, c] of years) { + const across = c.gmina_count > 1 ? ` · summed across ${c.gmina_count} municipalities` : ""; + const adopted = c.resolution_date ? ` · adopted ${c.resolution_date}` : ""; + lines.push(` - ${year}: ${formatNumber(c.value_pln)} PLN${across}${adopted}`); + } + + lines.push("", r.meta.coverage_note); + if (r.meta.as_of) lines.push(`Most recent tender notice: ${r.meta.as_of}.`); + return lines.join("\n"); +} + +// ── Rental yield formatting ──────────────────────────────────────── + +// Version-agnostic substring (no /api prefix) so the backend discovery note is stripped whether it +// arrives as /api/... (legacy) or /api/v1/... (post-migration) — the formatter re-renders it itself. +const RENTAL_YIELD_LOCATIONS_PATH = "rental-yield/locations"; + +export function formatRentalYield(r: RentalYieldResponse): string { + const { rent, transaction: tx } = r.inputs; + const q = r.quality; + const lines: string[] = [`Gross rental yield — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""]; + + lines.push( + r.result.gross_yield_pct != null + ? `Gross yield: ${r.result.gross_yield_pct}% per year` + : `Gross yield: N/A (coverage: ${q.coverage})`, + ); + + lines.push(""); + lines.push("Calculation (gross, top-line — no vacancy/management/tax/maintenance):"); + lines.push( + rent.median_monthly_asking_per_m2 != null && rent.annualized_per_m2 != null + ? ` Annualized rent: ${formatPLNExact(rent.median_monthly_asking_per_m2)}/m²/mo × 12 = ${formatPLN(rent.annualized_per_m2)}/m²/yr` + : " Annualized rent: N/A", + ); + lines.push( + tx.median_price_per_m2 != null + ? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)` + : ` Median transaction price: N/A (${r.segment.market_type} market)`, + ); + lines.push(" (market median — fractional shares & non-market deeds excluded)"); + + lines.push(""); + const rentN = rent.sample_n != null + ? `${formatNumber(rent.sample_n)} rent offer${rent.sample_n === 1 ? "" : "s"}${offerDateSuffix(rent.snapshot_date)}` + : "no rent data"; + const txN = tx.sample_n != null + ? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}` + : "no transaction data"; + lines.push(`Samples: ${rentN}, ${txN}`); + lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`); + if (q.as_of) lines.push(`Transaction data as of: ${q.as_of}`); + + lines.push(...distributionLines(r.distribution.asking_rent_monthly_per_m2, r.distribution.transaction_price_per_m2)); + + // Skip the REST-flavored discovery note (it names the HTTP path) — MCP surfaces the same + // cross-link as a tool tip below, so an LLM gets the tool name, not a URL it can't call. + const visibleNotes = q.notes.filter((n) => !n.includes(RENTAL_YIELD_LOCATIONS_PATH)); + if (visibleNotes.length > 0) { + lines.push("", "Notes:"); + for (const n of visibleNotes) lines.push(` - ${n}`); + } + + // Discovery cross-link: county resolved but there is no rent coverage → point the LLM + // at the catalog TOOL so it stops guessing which cities are covered. Reacts to coverage, not to + // the REST note string (decoupled). + if (q.coverage === "no_rental_data") { + lines.push("", "Tip: call list_rental_yield_locations to see which cities have rental-yield coverage."); + } + + return lines.join("\n"); +} + +// Discovery catalog formatter. Entries arrive pre-sorted (rent_sample_n desc) from the API. +export function formatRentalYieldLocations(r: RentalYieldLocationsResponse): string { + const { data, meta } = r; + if (data.length === 0) { + return "No rental-yield-covered locations match."; + } + const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : ""; + const lines: string[] = [ + `Rental-yield coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`, + "", + ]; + for (const loc of data) { + lines.push( + `- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.rent_sample_n)}, ${loc.confidence} confidence`, + ); + } + return lines.join("\n"); +} + +// ── Price spread formatting ───────────────────────────── + +// Version-agnostic substring (no /api prefix) — strips the backend note for both /api/ and /api/v1/. +const PRICE_SPREAD_LOCATIONS_PATH = "price-spread/locations"; + +export function formatPriceSpread(r: PriceSpreadResponse): string { + const { asking, transaction: tx } = r.inputs; + const q = r.quality; + const spread = r.result.spread_pct; + const lines: string[] = [`Asking-vs-transaction price spread — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""]; + + lines.push( + spread != null + ? `Spread: ${spread > 0 ? "+" : ""}${spread}% (asking ${spread >= 0 ? "above" : "below"} transaction)` + : `Spread: N/A (coverage: ${q.coverage})`, + ); + + lines.push(""); + lines.push("Calculation ((asking − transaction) / transaction × 100):"); + lines.push( + asking.median_price_per_m2 != null + ? ` Median asking price: ${formatPLN(asking.median_price_per_m2)}/m² (apartments for sale)` + : " Median asking price: N/A", + ); + lines.push( + tx.median_price_per_m2 != null + ? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)` + : ` Median transaction price: N/A (${r.segment.market_type} market)`, + ); + lines.push(" (market median — fractional shares & non-market deeds excluded)"); + + lines.push(""); + const askN = asking.sample_n != null + ? `${formatNumber(asking.sample_n)} sale offer${asking.sample_n === 1 ? "" : "s"}${offerDateSuffix(asking.snapshot_date)}` + : "no asking data"; + const txN = tx.sample_n != null + ? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}` + : "no transaction data"; + lines.push(`Samples: ${askN}, ${txN}`); + lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`); + if (q.as_of) lines.push(`Transaction data as of: ${q.as_of}`); + + lines.push(...distributionLines(r.distribution.asking_sale_per_m2, r.distribution.transaction_price_per_m2)); + + // Drop the REST-flavored discovery note (names the HTTP path) — the tool tip below gives the LLM + // the tool name instead of a URL it can't call. + const visibleNotes = q.notes.filter((n) => !n.includes(PRICE_SPREAD_LOCATIONS_PATH)); + if (visibleNotes.length > 0) { + lines.push("", "Notes:"); + for (const n of visibleNotes) lines.push(` - ${n}`); + } + + // Discovery cross-link: county resolved but there is no sale coverage → point at the catalog TOOL. + if (q.coverage === "no_asking_data") { + lines.push("", "Tip: call list_price_spread_locations to see which cities have asking-price coverage."); + } + + return lines.join("\n"); +} + +export function formatPriceSpreadLocations(r: PriceSpreadLocationsResponse): string { + const { data, meta } = r; + if (data.length === 0) { + return "No price-spread-covered locations match."; + } + const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : ""; + const lines: string[] = [ + `Price-spread coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`, + "", + ]; + for (const loc of data) { + lines.push( + `- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.asking_sample_n)}, ${loc.confidence} confidence`, + ); + } + return lines.join("\n"); +} + +// ── Valuation formatting (comparable-sales apartment estimate) ────── + +// One comparable line. market_type / district appended only when present. +function valuationCompLine(c: ValuationComparable): string { + const parts = [`${formatNumber(c.distance_m)} m`, c.transaction_date, formatArea(c.area_m2), `${formatPLN(c.price_per_m2)}/m²`]; + if (c.market_type) parts.push(c.market_type); + if (c.district) parts.push(c.district); + return ` - ${parts.join(" · ")}`; +} + +// Render a comparable-sales apartment valuation. The disclaimer text (q.note) is authored server-side +// and carried verbatim. +export function formatValuation(r: ValuationResponse): string { + const { result: res, inputs, quality: q, segment } = r ?? ({} as ValuationResponse); + const loc = r?.location; + // Shape guard (defensive): a truncated or proxied response used to blow up here with a raw TypeError. + // The server never emits such a body; this only makes the failure mode boring. + if (!res || !inputs || !q || !segment || !loc) { + return "Unexpected response from the Cenogram API — the valuation could not be rendered. Try again shortly."; + } + const where = + loc.lat != null && loc.lng != null + ? `near ${loc.lat}, ${loc.lng}` + : loc.county_code + ? `county ${loc.county_code}` + : "the requested point"; + const lines: string[] = [`Apartment value estimate — ${formatArea(segment.area_m2)} ${where}`, ""]; + + // no_data / not_covered → no estimate. On no_data the 5-credit charge is refunded server-side. + if (res.estimated_value == null) { + // lat/lng are always echoed for a point query, so a no_data with BOTH null means the parcelId itself + // never resolved — say so instead of blaming the neighbourhood for having too few sales. + const parcelUnresolved = q.coverage !== "not_covered" && loc.lat == null && loc.lng == null; + lines.push( + q.coverage === "not_covered" + ? "No estimate: outside the covered property type (v1 covers apartments only)." + : parcelUnresolved + ? "No estimate: that parcel could not be resolved (unknown id, or no geometry on record). The credit is refunded — check the id, or address the apartment by lat/lng." + : "No estimate: too few comparable transactions near this point (credit refunded). Try a point in a denser urban area.", + ); + if (q.note) lines.push("", q.note); + return lines.join("\n"); + } + + lines.push(`Estimated value: ${formatPLN(res.estimated_value)}${res.price_per_m2 != null ? ` (${formatPLN(res.price_per_m2)}/m²)` : ""}`); + const likely = res.value_range_likely; + const wide = res.value_range_wide; + if (likely?.low != null && likely.high != null) lines.push(`Likely range: ${formatPLN(likely.low)} – ${formatPLN(likely.high)}`); + if (wide?.low != null && wide.high != null) lines.push(`Wide range: ${formatPLN(wide.low)} – ${formatPLN(wide.high)}`); + if (res.confidence_band) lines.push(`Confidence: ${res.confidence_band}${res.confidence != null ? ` (${res.confidence})` : ""}`); + + lines.push(""); + const radius = inputs.radius_m != null ? ` within ${formatNumber(inputs.radius_m)} m` : ""; + lines.push(`Based on ${formatNumber(inputs.comps_total)} comparable transaction${inputs.comps_total === 1 ? "" : "s"}${radius}, last ${inputs.window_months} months.`); + if (q.as_of) lines.push(`Transaction data as of: ${q.as_of} (varies by county — publication lag)`); + + if (Array.isArray(inputs.comparables) && inputs.comparables.length > 0) { + const shown = inputs.comparables.slice(0, 5); + lines.push("", `Comparables (nearest ${shown.length}):`); + for (const c of shown) lines.push(valuationCompLine(c)); + } + + if (q.note) lines.push("", q.note); + return lines.join("\n"); +} + +// ── Parcel report (composite dossier) ────────────────────────────── + +// Four-state gloss for a per-parcel section, kept EXPLICIT (the literal state token stays visible so the +// model never has to guess): covered = a definitive positive, covered_no_data = checked and nothing found +// (still billed), not_covered = outside our data (refunded), not_computed = could not finish (refunded, +// retry). Any unexpected token renders verbatim. +function fourStateGloss(coverage: string): string { + switch (coverage) { + case "covered": return "covered"; + case "covered_no_data": return "covered_no_data (checked — nothing found, still billed)"; + case "not_covered": return "not_covered (outside our data — refunded)"; + case "not_computed": return "not_computed (could not finish in time — refunded, retry)"; + default: return coverage; + } +} + +// A NUMERIC-or-string wire value coerced to a number (the API sends some NUMERIC columns as strings). +function toNum(v: unknown): number | null { + if (v == null) return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +// Drop the null-distance entries (no object of that kind within range) and sort nearest-first. +function nearestDistances(entries: Array<[string, number | null]>): Array<[string, number]> { + return entries + .filter((d): d is [string, number] => d[1] != null) + .sort((a, b) => a[1] - b[1]); +} + +// One compact detail suffix for a covered section (empty string when there is nothing extra to say). +function reportSectionDetail(name: string, s: ReportSection): string { + const covered = s.coverage === "covered"; + switch (name) { + case "flood": { + if (!covered) return ""; + const risk = typeof s.flood_risk === "string" ? s.flood_risk : null; + const pct = toNum(s.pct_in_zone); + return risk ? `${risk} risk${pct != null ? `, ${pct}% of the parcel in the mapped zone` : ""}` : ""; + } + case "heritage": { + if (!covered) return ""; + const status = typeof s.heritage_status === "string" ? s.heritage_status : null; + const sites = toNum(s.site_count); + return status ? `${status}${sites != null ? `, ${sites} listing(s)` : ""}` : ""; + } + case "landslide": { + if (!covered) return ""; + const risk = typeof s.landslide_risk === "string" ? s.landslide_risk : null; + return risk ? LANDSLIDE_RISK_NOTE[risk] ?? risk : ""; + } + case "surroundings": { + if (!covered) return ""; + // Nearest of the nuisance distances (a null = none within the search radius, never "none exists"). + const dists = nearestDistances([ + ["cemetery", toNum(s.cemetery_distance_m)], + ["landfill", toNum(s.landfill_distance_m)], + ["sewage treatment", toNum(s.sewage_treatment_distance_m)], + ["industrial area", toNum(s.industrial_area_distance_m)], + ["industrial plant", toNum(s.industrial_plant_distance_m)], + ["livestock farm", toNum(s.livestock_farm_distance_m)], + ]); + if (dists.length === 0) return "no mapped nuisance object within range"; + return dists.slice(0, 3).map(([k, m]) => `${k} ${Math.round(m)} m`).join(", "); + } + case "transit": { + if (!covered) return ""; + const modes = nearestDistances([ + ["rail", toNum(s.rail_distance_m)], + ["metro", toNum(s.metro_distance_m)], + ["tram", toNum(s.tram_distance_m)], + ["bus", toNum(s.bus_distance_m)], + ]); + if (modes.length === 0) return ""; + return modes.map(([k, m]) => `${k} ${Math.round(m)} m`).join(", "); + } + case "planning": { + if (!covered) return ""; + const rows = Array.isArray(s.data) ? (s.data as Array>) : []; + const symbols = [...new Set(rows.map((r) => r.zone_symbol).filter((x): x is string => typeof x === "string"))]; + return symbols.length > 0 ? `zones: ${symbols.join(", ")}` : `${rows.length} zone row(s)`; + } + case "buildings": { + if (!covered) return ""; + const rows = Array.isArray(s.data) ? s.data : []; + return `${rows.length} building(s) on the parcel`; + } + case "permits": { + if (!covered) return ""; + const rows = Array.isArray(s.data) ? s.data : []; + return `${rows.length} registered case(s)`; + } + case "farmland": { + if (!covered) return ""; + const area = toNum(s.eligible_area_m2); + const pct = toNum(s.pct_of_parcel); + return area != null ? `${formatArea(area)} eligible${pct != null ? ` (${pct}% of parcel)` : ""}` : ""; + } + default: + return ""; + } +} + +// One compact transaction line for the report's history section (newest-first, capped upstream at 20). +function reportTxLine(r: Record): string { + const date = typeof r.transaction_date === "string" ? r.transaction_date.split("T")[0] : "?"; + const type = PROPERTY_TYPES[Number(r.property_type)] || `Type ${r.property_type}`; + const market = MARKET_TYPES[Number(r.market_type)] || `Market ${r.market_type}`; + const price = formatPLN(toNum(r.price_gross)); + const area = toNum(r.usable_area_m2); + const ppm2 = toNum(r.price_per_m2); + const tail = [area != null ? formatArea(area) : null, ppm2 != null ? `${formatPLN(ppm2)}/m2` : null].filter(Boolean).join(", "); + return ` - ${date} — ${type}, ${market} — ${price}${tail ? ` (${tail})` : ""}`; +} + +// One market-context price level as a readable line. coverage is the statistical canon: suppressed hides +// the median (too few sales), no_data means no sample. +function reportMarketLine(label: string, lvl: ReportMarketLevel): string { + if (lvl.coverage === "no_data") return ` - ${label}: no data`; + if (lvl.median_price_per_m2 == null) return ` - ${label}: withheld (only ${lvl.n} sale(s) — too few to publish)`; + const flag = lvl.coverage === "low_sample" ? " [small sample]" : ""; + return ` - ${label}: ${formatPLN(lvl.median_price_per_m2)}/m2 (n=${lvl.n})${flag}`; +} + +// Human-readable billing outcome for the report's footer: the net numbers plus WHY (billing.rule). +function reportBillingFooter(billing: { charged: number; refunded: number; rule: string }): string { + const why: Record = { + full: "billed in full — at least one enrichment layer had data", + core_floor: "resolved, but no enrichment layer had data — only the parcel-core floor is billed, the rest refunded", + total_miss_refund: "fully refunded — the parcel could not be resolved", + not_computed_refund: "fully refunded — no layer could be computed right now (retry-worthy)", + disabled: "fully refunded — the composite report is temporarily unavailable", + demo: "no charge (demo / web session)", + }; + const reason = why[billing.rule] ?? billing.rule; + return `Billing: ${billing.charged} charged, ${billing.refunded} refunded — ${reason}`; +} + +// The layers rendered in report order, with a readable label each. +const REPORT_LAYER_ORDER: Array<[string, string]> = [ + ["flood", "Flood risk"], + ["heritage", "Heritage listing"], + ["landslide", "Landslide risk"], + ["surroundings", "Nuisance surroundings"], + ["transit", "Public transport"], + ["planning", "Planning (general plan)"], + ["buildings", "Buildings"], + ["permits", "Building activity"], + ["farmland", "Agricultural land"], +]; + +export function formatParcelReport(res: ParcelReportResponse): string { + const p = res.parcel; + const id = p.parcel_id ?? p.parcel_key ?? "(parcel id requires a paid plan)"; + + // A total miss, or the layer is unavailable: the core never resolved. Say so plainly (the billing + // footer explains the refund). An unavailable layer also surfaces as a top-level not_computed, but it + // is NOT transient — so it gets its own header (no misleading "retry"). + if (res.coverage !== "covered") { + const head = res.coverage === "not_covered" + ? `Parcel ${id} could not be resolved — it is not in our cadastral copy.` + : res.billing.rule === "disabled" + ? `The composite report is temporarily unavailable for parcel ${id}.` + : `Parcel ${id} could not be resolved right now (a live lookup did not finish — retry).`; + return `${head}\n\n---\n${reportBillingFooter(res.billing)}`; + } + + const lines: string[] = [`Parcel report: ${id}`]; + + // Core identity + facts. + const place = [p.district, p.county_name, p.voivodeship_name].filter(Boolean).join(", "); + if (place) lines.push(place); + const facts = [ + p.area_m2 != null ? `Area: ${formatArea(p.area_m2)}` : null, + p.land_use ? `Land use: ${p.land_use}` : null, + p.mpzp_designation ? `Plan designation: ${p.mpzp_designation}` : null, + ].filter(Boolean); + if (facts.length > 0) lines.push(facts.join(" | ")); + const asOf = res.as_of ? ` (as of ${res.as_of.split("T")[0]})` : ""; + lines.push(`Core: covered${asOf}`); + + // Enrichment layers (each four-state explicit). + lines.push("", "Enrichment layers:"); + const sections = res.sections; + for (const [key, label] of REPORT_LAYER_ORDER) { + const s = sections[key as keyof typeof sections] as ReportSection; + const detail = reportSectionDetail(key, s); + lines.push(`- ${label}: ${fourStateGloss(s.coverage)}${detail ? ` — ${detail}` : ""}`); + } + + // Transaction history. + const tx = sections.transactions; + const total = toNum(tx.total) ?? 0; + const rows = Array.isArray(tx.data) ? (tx.data as Array>) : []; + lines.push("", `Transaction history: ${fourStateGloss(tx.coverage)}`); + if (tx.coverage === "covered") { + lines.push(` ${total} recorded${rows.length < total ? ` (showing newest ${rows.length})` : ""}:`); + for (const r of rows) lines.push(reportTxLine(r)); + if (rows.length < total) lines.push(` … call search_transactions(parcelId="${p.parcel_id ?? id}") for the full history.`); + } + + // Local price context (market_context — statistical canon). + const m: ReportMarketContext = sections.market_context; + lines.push("", "Local price context (median zł/m², last 12 months):"); + if (m.coverage === "no_data") { + lines.push(" - no data for this location"); + } else { + lines.push(reportMarketLine("County", m.county)); + lines.push(reportMarketLine(m.locality.district ? `Locality (${m.locality.district})` : "Locality", m.locality)); + } + + // Municipal context (location_context — demographics + infrastructure signals). + const loc: ReportLocationContext = sections.location_context; + lines.push("", `Municipal context${loc.gmina_teryt ? ` (gmina ${loc.gmina_teryt})` : ""}:`); + const demo = loc.demographics; + if (demo.coverage === "no_data") { + lines.push(" - Demographics: no data"); + } else { + const inds = Object.values(demo.indicators).slice(0, 4); + const indText = inds.map((i) => { + const years = Object.keys(i.values); + const latest = years.length > 0 ? i.values[years[years.length - 1]!] : null; + return latest != null ? `${i.name} ${formatNumber(latest)} ${i.unit}`.trim() : i.name; + }); + lines.push(` - Demographics${demo.name ? ` (${demo.name})` : ""}: ${indText.length > 0 ? indText.join("; ") : "—"}`); + } + const infra = loc.infra_signals; + if (infra.coverage === "no_data") { + lines.push(" - Infrastructure signals: no data"); + } else { + const tenderTotal = Object.values(infra.tenders.by_category).reduce((a, b) => a + b, 0); + const kposk = infra.kposk.in_agglomeration ? "in a collective-sewerage agglomeration" : "not in a collective-sewerage agglomeration"; + lines.push(` - Infrastructure signals: ${tenderTotal} municipal tender(s) in the last ${infra.tenders.window_months} months; ${kposk}`); + } + + if (res.note) lines.push("", res.note); + lines.push("", "---", reportBillingFooter(res.billing)); return lines.join("\n"); } diff --git a/src/index.ts b/src/index.ts index b9cf095..96b22a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ #!/usr/bin/env node +import { Sentry } from "./sentry.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { readFileSync, realpathSync } from "node:fs"; import { fetch } from "undici"; -import { registerTools } from "./tools.js"; +import { registerTools, experimentalToolsEnabled } from "./tools.js"; import { dispatchAuth, sanitizeForLog } from "./auth-dispatch.js"; import { requestContext } from "./request-context.js"; +import { signupUrl } from "./error-messages.js"; +import { channelSrc, isHttpMode } from "./transport-mode.js"; function logAuth(payload: Record, level: "info" | "warn" | "error"): void { process.stderr.write(JSON.stringify({ level, ...payload }) + "\n"); @@ -33,38 +36,62 @@ try { // ── Server factory ────────────────────────────────────────────────── +/** + * What every client is told before it calls a tool. + * + * Built per call rather than held in a constant, because the `?src=` tags below follow the + * transport. Exported so a test can read the text back — both transports share every line of it + * except those tags, so a tag written as a literal is silently wrong for one of them. + */ +export function serverInstructions(): string { + return [ + "Cenogram MCP Server - 8M+ verified real estate transactions from Poland's official RCN registry (Rejestr Cen Nieruchomości). Transaction prices from notarial deeds - NOT asking/listing prices. Data from 2003 to present, 380 counties, refreshed every ~2 weeks.", + "", + "CRITICAL - District names (ALWAYS verify first):", + "- NEVER guess district names. Call list_locations(search=\"city\") first.", + "- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście", + "- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.", + "- Most cities (Gdańsk, Gdynia, Sopot, Poznań, Wrocław): just the city name, no sub-districts", + "- Neighborhoods/osiedla (Nowy Dwór, Oliwa, Jeżyce) are NOT TERYT districts. Start with search_by_area (radiusKm 0.5-1.0), then refine with search_by_polygon if needed.", + "- TERYT hierarchy: For precise administrative filtering (avoids name ambiguity), use list_locations(parent) to browse TERYT codes, then search_transactions(teryt=code).", + "- Use 'location' for quick city searches, 'teryt' when you need exact administrative boundaries.", + "", + "Workflows:", + "- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions", + // Only documented when those tools are actually registered. + ...(experimentalToolsEnabled() + ? [ + "- Rental yield: (experimental - may change/withdraw) list_rental_yield_locations (catalog of covered cities) → get_rental_yield(location|teryt) - indicative gross yield from asking rent vs RCN transaction prices. County level only (miasta na prawach powiatu), or Warszawa district via 6-digit teryt. For any name that is not a major city you know is covered, call the catalog FIRST or pass a county teryt - towns inside a larger powiat and non-Warszawa districts 404.", + "- Price spread: (experimental - may change/withdraw) list_price_spread_locations (catalog of covered cities) → get_price_spread(location|teryt) - asking-vs-transaction price spread %, marketType all|secondary|primary. County level only (miasta na prawach powiatu), or Warszawa district via 6-digit teryt. For any name that is not a major city you know is covered, call the catalog FIRST or pass a county teryt - towns inside a larger powiat and non-Warszawa districts 404.", + ] + : []), + "- Compare locations: list_locations → compare_locations (2-5 districts, requires at least one filter e.g. propertyType). Add includeDemographics=true for a GUS BDL block per district.", + "- Demographics & local stats: get_demographics(location|teryt) — GUS BDL indicators (population, economy, housing, planning, safety, education, prices). A city name resolves to county level; pass a 6/7-digit teryt for gmina-level detail.", + "- Parcel lookup: search_parcels(q, min 3 chars) → search_by_area (use returned lat/lng)", + "- Parcel resolve: resolve_parcel(parcelId | q | lat+lng) — turn a full parcel id, a UUID, a 'locality name + parcel number', or a coordinate into a concrete parcel; feed the returned id to search_transactions(parcelId) for its sale history.", + "- Per-building detail: search_transactions → get_building_breakdown(transaction_id) — footprint, storeys, est. total floor area per building (searches return per-transaction sums inline)", + "- Surroundings (nearby nuisances): search_transactions → get_transaction_surroundings(transaction_id) — per-plot distance to the nearest cemetery, landfill, sewage treatment plant, industrial/storage area, large industrial plant, and intensive livestock farm. A null distance = nothing within the search radius in reference data, never a guarantee.", + "- Address search: search_transactions(location, street, buildingNumber)", + "- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity", + "- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices", + "- TERYT drill-down: list_locations() → list_locations(parent=voivodeshipCode) → list_locations(parent=countyCode) → search_transactions(teryt=municipalityCode)", + "", + "Data notes:", + "- Median/average prices are market-based: fractional ownership shares (share_basis=\"fraction\") and non-market deeds (public tenders, foreclosures, privileged/subsidized sales) are excluded from price aggregates. Transaction counts and coverage stay full. search_transactions/search_by_area flag fractional-share rows so you can spot which comparables are partial.", + "- price_per_m2 only meaningful for apartments (propertyType=\"unit\")", + "- Field provenance (search_transactions/search_by_area/search_by_polygon): per-record values are from the notarial deed (RCN) by default and carry no marker. Values we computed (parcel area summed across plots or converted from hectares; an inferred or reclassified property type) and approximated streets are flagged inline with a neutral [...] note — mirroring the \"Z RCN / Obliczone / Uzupełnione\" tiers on cenogram.pl. A field being absent from a result does NOT mean the deed omitted it: the county may simply not report that field.", + "- Rooms (izby) filter: search_transactions/search_by_area/search_by_polygon/compare_locations accept `rooms` (array, e.g. [\"2\",\"3\"]; \"8plus\" = 8 or more). Units only (propertyType=\"unit\"); rows with no room count are excluded. NOTE: RCN counts izby (chambers - a kitchen counts as one izba), so values run higher than portal \"pokoje\" listings.", + "- Floor (piętro) filter: the same tools accept `floor` (array, e.g. [\"0\",\"1\",\"2\"]; \"0\" = ground/parter, negatives = basement, \"10plus\" = 10 or more, \"0plus\" = ground and above, \"unknown\" = no floor recorded). Units only; this is the unit's floor, NOT the number of building storeys. Rows with no floor are excluded unless \"unknown\" is included.", + "- Results paginated (default 10-20). Use page parameter for more.", + `- For §79-compliant export table or interactive map - direct user to https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}`, + `- Deep link / permalink to the map: from a transaction's \`id\` and its \`Location: °N, °E\` line, build https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}#v=1&lat=&lng=&z=16&tx= (drop the °N/°E; lat = the °N number, lng = the °E number) — opens that exact transaction on the map. Omit &tx= for a link centered on the area without a specific row open.`, + ].join("\n"); +} + export function createMcpServer(apiKey?: string): McpServer { const server = new McpServer( { name: "cenogram-mcp-server", version: PKG_VERSION }, - { - instructions: [ - "Cenogram MCP Server - 8M+ verified real estate transactions from Poland's official RCN registry (Rejestr Cen Nieruchomości). Transaction prices from notarial deeds - NOT asking/listing prices. Data from 2003 to present, 380 counties, refreshed every ~2 weeks.", - "", - "CRITICAL - District names (ALWAYS verify first):", - "- NEVER guess district names. Call list_locations(search=\"city\") first.", - "- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście", - "- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.", - "- Most cities (Gdańsk, Gdynia, Sopot, Poznań, Wrocław): just the city name, no sub-districts", - "- Neighborhoods/osiedla (Nowy Dwór, Oliwa, Jeżyce) are NOT TERYT districts. Start with search_by_area (radiusKm 0.5-1.0), then refine with search_by_polygon if needed.", - "- TERYT hierarchy: For precise administrative filtering (avoids name ambiguity), use list_locations(parent) to browse TERYT codes, then search_transactions(teryt=code).", - "- Use 'location' for quick city searches, 'teryt' when you need exact administrative boundaries.", - "", - "Workflows:", - "- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions", - "- Compare locations: list_locations → compare_locations (2-5 districts, requires at least one filter e.g. propertyType)", - "- Parcel lookup: search_parcels(q, min 3 chars) → search_by_area (use returned lat/lng)", - "- Address search: search_transactions(location, street, buildingNumber)", - "- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity", - "- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices", - "- TERYT drill-down: list_locations() → list_locations(parent=voivodeshipCode) → list_locations(parent=countyCode) → search_transactions(teryt=municipalityCode)", - "", - "Data notes:", - "- price_per_m2 only meaningful for apartments (propertyType=\"unit\")", - "- API has no rooms filter - use area as proxy (1-room: 20-35m², 2: 35-55m², 3: 55-90m², 4+: 80-130m²), then post-filter by rooms field in results", - "- Results paginated (default 10-20). Use page parameter for more.", - "- For §79-compliant export table or interactive map - direct user to cenogram.pl", - ].join("\n"), - }, + { instructions: serverInstructions() }, ); registerTools(server, apiKey); return server; @@ -73,7 +100,7 @@ export function createMcpServer(apiKey?: string): McpServer { // ── Start ─────────────────────────────────────────────────────────── async function main() { - const mode = process.argv.includes("--http") || process.env.MCP_TRANSPORT === "http" ? "http" : "stdio"; + const mode = isHttpMode() ? "http" : "stdio"; if (mode === "http") { const { createServer } = await import("node:http"); @@ -178,6 +205,7 @@ async function main() { res.writeHead(404).end(); } } catch (err) { + Sentry.captureException(err, { tags: { error_layer: "http_handler" } }); process.stderr.write(`HTTP error: ${String(err)}\n`); if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }).end( @@ -198,7 +226,7 @@ async function main() { if (!process.env.CENOGRAM_API_KEY) { process.stderr.write( "Error: CENOGRAM_API_KEY is required.\n" + - "Get your free API key at https://cenogram.pl/api\n" + + `Get your free API key at ${signupUrl()}\n` + "Then add it to your MCP config:\n" + ' "env": { "CENOGRAM_API_KEY": "cngrm_..." }\n', ); @@ -210,7 +238,13 @@ async function main() { } if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { - main().catch((err) => { + process.on("SIGTERM", () => { + void Sentry.flush(2000).then(() => process.exit(0)); + }); + + main().catch(async (err) => { + Sentry.captureException(err, { tags: { error_layer: "fatal" } }); + await Sentry.flush(2000); process.stderr.write(`Fatal: ${String(err)}\n`); process.exit(1); }); diff --git a/src/mappings.ts b/src/mappings.ts index 30414b4..f940d2e 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -45,16 +45,17 @@ export const UNIT_FUNCTIONS: Record = { 6: "Other (Inne)", }; -const UNIT_FUNCTION_MAP: Record = { +const UNIT_FUNCTION_MAP: Record = { residential: 1, commercial: 2, office: 3, production: 4, garage: 5, other: 6, + unknown: "unknown", // sentinel string — API resolves to IS NULL condition }; -export function mapUnitFunction(value: string | undefined): number | undefined { +export function mapUnitFunction(value: string | undefined): number | "unknown" | undefined { if (!value) return undefined; return UNIT_FUNCTION_MAP[value]; } @@ -74,7 +75,7 @@ export const BUILDING_TYPES: Record = { 129: "Other non-residential (Pozostałe niemieszkalne)", }; -const BUILDING_TYPE_MAP: Record = { +const BUILDING_TYPE_MAP: Record = { residential: 110, commercial: 121, industrial: 122, @@ -85,13 +86,102 @@ const BUILDING_TYPE_MAP: Record = { farm_utility: 127, hospital: 128, other_nonresidential: 129, + unknown: "unknown", // sentinel string — API resolves to IS NULL condition }; -export function mapBuildingType(value: string | undefined): number | undefined { +export function mapBuildingType(value: string | undefined): number | "unknown" | undefined { if (!value) return undefined; return BUILDING_TYPE_MAP[value]; } +// ── Deed-detail enum maps ─────────────────────────────────────────── +// Raw fields straight from the notarial deed. ENGLISH labels (MCP output is English) — established +// real-estate-law equivalents, not ad-hoc translations. Codes mirror the canonical label maps used +// across the product. + +// Rodzaj prawa (nier_prawo), codes 1-8. +export const OWNERSHIP_TYPES: Record = { + 1: "Land ownership", + 2: "Perpetual usufruct", + 3: "Cooperative ownership right", + 4: "Unit sale", + 5: "Ownership", + 6: "Unit ownership with appurtenant right", + 7: "Building ownership with appurtenant right", + 8: "Perpetual usufruct", +}; + +// Reverse-mapper for the ownership/legal-right filter: named enum → registry code(s). +// perpetual_usufruct expands to BOTH codes 2 and 8 — the registry records użytkowanie wieczyste +// under either code depending on the record, so filtering must include both. +// Multi-select: the array is expanded and joined into the CSV the API's resolveSmallintCsv accepts. +const OWNERSHIP_TYPE_FILTER_MAP: Record = { + land_ownership: "1", + perpetual_usufruct: "2,8", + cooperative_ownership: "3", + unit_sale: "4", + ownership: "5", + unit_ownership_with_appurtenant_right: "6", + building_ownership_with_appurtenant_right: "7", + unknown: "unknown", // sentinel string — API resolves to IS NULL condition +}; + +export function mapOwnershipTypes(values: string[] | undefined): string | undefined { + if (!values || values.length === 0) return undefined; + const codes = values.map((v) => OWNERSHIP_TYPE_FILTER_MAP[v]).filter(Boolean); + return codes.length > 0 ? codes.join(",") : undefined; +} + +// Strony transakcji (seller/buyer share one dictionary), codes 1-7. +export const PARTY_TYPES: Record = { + 1: "State Treasury", + 2: "Local-government unit", + 3: "Natural person", + 4: "Legal person", + 5: "Cooperative", + 6: "State legal person", + 7: "Other legal person", +}; + +// Land use (land_use) — RCN string codes from the GPKG, 5 values present. +export const LAND_USES: Record = { + gruntyZabudowaneIZurbanizowane: "Built-up and urbanized land", + gruntyRolne: "Agricultural land", + gruntyLesne: "Forest land", + terenyKomunikacyjne: "Transport land", + inne: "Other", +}; + +// ── Transaction type enum maps ────────────────────────────────────── + +export const TRANSACTION_TYPES: Record = { + 1: "Free market (Wolny rynek)", + 3: "Auction (Przetargowa)", + 4: "Non-auction (Bezprzetargowa)", + 5: "Subsidized (Z bonifikatą)", + 9: "Public purpose (Na cel publiczny)", + 10: "Foreclosure (Egzekucyjna)", +}; + +const TRANSACTION_TYPE_MAP: Record = { + free_market: 1, + auction: 3, + non_auction: 4, + subsidized: 5, + public_purpose: 9, + foreclosure: 10, + unknown: "unknown", // sentinel string — API resolves to IS NULL condition +}; + +export function mapTransactionTypes(values: string[] | undefined): string | undefined { + if (!values || values.length === 0) return undefined; + const mapped = values + .map(v => TRANSACTION_TYPE_MAP[v]) + .filter((v): v is number | "unknown" => v !== undefined); + if (mapped.length === 0) return undefined; + return mapped.join(","); +} + // ── Bbox conversion ───────────────────────────────────────────────── /** Convert lat/lng/radius to bbox [minLng, minLat, maxLng, maxLat] (lng-first!) */ @@ -112,7 +202,7 @@ export function radiusKmToBbox( // ── Location filtering ────────────────────────────────────────────── -function stripDiacritics(s: string): string { +export function stripDiacritics(s: string): string { return s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[łŁ]/g, (c) => c === "ł" ? "l" : "L"); } @@ -144,3 +234,72 @@ export const CITY_SUBDISTRICTS: ReadonlyMap = new Map export function expandDistrict(district: string): string[] { return CITY_SUBDISTRICTS.get(district)?.slice() ?? [district]; } + +// ── Normalized district resolution ────────────────────────────────── + +export function buildNormalizedMap(districts: string[]): Map { + const map = new Map(); + for (const d of districts) { + const key = stripDiacritics(d.toLowerCase()); + const existing = map.get(key); + if (existing) { + existing.push(d); + } else { + map.set(key, [d]); + } + } + for (const [cityName] of CITY_SUBDISTRICTS) { + const key = stripDiacritics(cityName.toLowerCase()); + if (!map.has(key)) { + map.set(key, [cityName]); + } + } + return map; +} + +let lastDistricts: string[] | null = null; +let normalizedMap: Map | null = null; + +function getNormalizedMap(districts: string[]): Map { + if (districts !== lastDistricts) { + normalizedMap = buildNormalizedMap(districts); + lastDistricts = districts; + } + return normalizedMap!; +} + +/** + * If `input` is a known multi-district city key (Warszawa/Kraków/Łódź, case- and + * diacritics-insensitive), return a fresh copy of its sub-districts. Otherwise null. + * Lets callers skip the /api/districts fetch for city keys — the + * sub-district list is a static map, so no API round-trip is needed to resolve it. + */ +export function tryResolveCityKey(input: string): string[] | null { + const normalized = stripDiacritics(input.trim().toLowerCase()); + for (const [cityName, subs] of CITY_SUBDISTRICTS) { + if (stripDiacritics(cityName.toLowerCase()) === normalized) { + return subs.slice(); + } + } + return null; +} + +export function resolveDistrict(input: string, allDistricts: string[]): string[] { + const city = tryResolveCityKey(input); + if (city) return city; + + const normalized = stripDiacritics(input.trim().toLowerCase()); + const map = getNormalizedMap(allDistricts); + + const canonicals = map.get(normalized); + if (canonicals) { + const result: string[] = []; + for (const c of canonicals) { + const expanded = expandDistrict(c); + result.push(...expanded); + } + return result; + } + + return [input]; +} diff --git a/src/oauth-jwt.ts b/src/oauth-jwt.ts index e6a6fc0..0f281c2 100644 --- a/src/oauth-jwt.ts +++ b/src/oauth-jwt.ts @@ -29,7 +29,7 @@ let cachedKid: string | undefined; async function getKeyPair(): Promise<{ key: CryptoKey; kid: string }> { const kid = process.env.OAUTH_JWT_KID; - const pem = (process.env.OAUTH_JWT_PUBLIC_KEY ?? "").replace(/\\\\n|\\n/g, "\n"); + const pem = (process.env.OAUTH_JWT_PUBLIC_KEY ?? "").replace(/\\+n/g, "\n"); if (!kid || !pem) throw new OAuthConfigError(); if (cachedKey && cachedKid === kid) return { key: cachedKey, kid }; cachedKey = await importSPKI(pem, "RS256"); diff --git a/src/sentry-scrub.ts b/src/sentry-scrub.ts new file mode 100644 index 0000000..6dadfbb --- /dev/null +++ b/src/sentry-scrub.ts @@ -0,0 +1,17 @@ +const SENSITIVE_HEADERS = new Set([ + "authorization", "cookie", "x-internal-auth", "x-api-key", +]); + +const API_KEY_PATTERN = /cngrm_[a-f0-9]+/g; + +export function scrubHeaders(headers: Record): Record { + const scrubbed: Record = {}; + for (const [key, value] of Object.entries(headers)) { + scrubbed[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? "[Filtered]" : value; + } + return scrubbed; +} + +export function scrubString(s: string): string { + return s.replace(API_KEY_PATTERN, "[Filtered]"); +} diff --git a/src/sentry.ts b/src/sentry.ts new file mode 100644 index 0000000..21e7aaa --- /dev/null +++ b/src/sentry.ts @@ -0,0 +1,30 @@ +import * as Sentry from "@sentry/node"; +import { scrubHeaders, scrubString } from "./sentry-scrub.js"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV || "development", + release: process.env.GIT_SHA || "unknown", + enabled: process.env.NODE_ENV === "production" && !!process.env.SENTRY_DSN, + tracesSampleRate: 0, + profilesSampleRate: 0, + initialScope: { + tags: { service: "mcp" }, + }, + beforeSend(event) { + if (event.request?.headers) { + event.request.headers = scrubHeaders(event.request.headers); + } + if (event.request?.url) { + event.request.url = scrubString(event.request.url); + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrubString(ex.value); + } + } + return event; + }, +}); + +export { Sentry }; diff --git a/src/tools.ts b/src/tools.ts index 5763af0..b1463c2 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,3 +1,4 @@ +import { Sentry } from "./sentry.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { @@ -9,29 +10,73 @@ import { getPriceHistogram, getTransactionsSummary, searchParcels, + resolveParcel, + getParcelReport, searchByPolygon, compareLocations, + getRentalYield, + getRentalYieldLocations, + getPriceSpread, + getPriceSpreadLocations, + getValuation, + getBuildingBreakdown, + getTransactionFlood, + getTransactionHeritage, + getTransactionLandslide, + getTransactionSurroundings, + getTransactionTransit, + getTransactionPermits, + getTransactionPlanning, + getTransactionFarmland, + getDemographics, + getInfrastructureSignals, + decodeOAuthCtx, + OAUTH_CTX_PREFIX, } from "./api-client.js"; import type { CreditInfo } from "./api-client.js"; +import { signupUrl } from "./error-messages.js"; +import { channelSrc, isHttpMode } from "./transport-mode.js"; +import { sanitizeForLog } from "./auth-dispatch.js"; import { formatTransactionList, formatMarketOverview, formatPriceStats, formatHistogram, formatParcelResults, + formatParcelResolve, + formatParcelReport, formatSpatialResults, formatCompareResults, formatLocationHierarchy, + formatRentalYield, + formatRentalYieldLocations, + formatPriceSpread, + formatPriceSpreadLocations, + formatValuation, + formatBuildingBreakdown, + formatFloodBreakdown, + formatHeritageBreakdown, + formatLandslideBreakdown, + formatSurroundings, + formatTransitBreakdown, + formatPermitsBreakdown, + formatPlanningBreakdown, + formatFarmland, + formatDemographics, + formatInfrastructureSignals, + MARKET_CAVEAT, } from "./formatters.js"; import { mapPropertyType, mapMarketType, mapUnitFunction, mapBuildingType, + mapOwnershipTypes, + mapTransactionTypes, radiusKmToBbox, filterByLocation, - expandDistrict, - CITY_SUBDISTRICTS, + resolveDistrict, + tryResolveCityKey, } from "./mappings.js"; // ── Helpers ───────────────────────────────────────────────────────── @@ -40,6 +85,11 @@ function sanitizeInput(s: string, maxLen = 50): string { return s.replace(/[<>]/g, "").slice(0, maxLen); } +// Mirror of the server-side guard. Validating here means a malformed id is +// rejected before the API call — the REST endpoint bills 1 credit per call even for a garbage id that +// resolves to empty, so zod-validating up front protects the caller's credit. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } @@ -49,21 +99,39 @@ function formatCreditFooter(creditInfo: CreditInfo | null): string { return `\n---\nAPI tokens: ${creditInfo.balance} remaining (query cost: ${creditInfo.cost})`; } +// Two very different situations produce a missing key, and the old wording described both as +// a defect. On stdio it is the ordinary state of someone who has not got a key yet - telling +// them to file a bug sends them nowhere. Over HTTP the auth context is established before any +// tool runs, so its absence really is a defect on our side. function requireApiKey(apiKey: string | undefined): asserts apiKey is string { if (!apiKey) { + if (isHttpMode()) { + throw new Error( + "Missing auth context on the hosted server - this is a bug on our side, not something " + + "you can fix. Please report it: https://github.com/cenogram/mcp-server/issues", + ); + } throw new Error( - "Internal: missing auth context. " + - "stdio: set CENOGRAM_API_KEY env var (key from https://cenogram.pl/api/keys). " + - "HTTP MCP: report bug - https://github.com/cenogram/mcp-server/issues", + `No Cenogram API key configured. Get a free key at ${signupUrl()}, then add it to your ` + + 'MCP config: "env": { "CENOGRAM_API_KEY": "cngrm_..." }', ); } } -function extractKeyPrefix(apiKey: string | undefined): string | null { - if (!apiKey) return null; - if (apiKey.startsWith("\x01")) return "oauth"; - if (apiKey.startsWith("cngrm_")) return apiKey.slice(0, 10); - return apiKey.slice(0, 4); +// Decode the caller identity from the auth context, for logging + Sentry only. +// user_id carries two shapes: a UUID for OAuth callers, the key prefix otherwise. key_prefix stays a +// separate field so a log consumer can tell the channel apart; the two shapes are visually distinct too. +function decodeAuthIdentity(apiKey: string | undefined): { userId: string | null; keyPrefix: string | null } { + if (!apiKey) return { userId: null, keyPrefix: null }; + // Gate on the \x01 prefix BEFORE the slice fallback: a malformed OAuth ctx (decode = null) must not fall + // through to apiKey.slice(0,4), which would leak the raw \x01 control byte into the logs/Sentry. Keep the + // stable "oauth" label (the previous extractKeyPrefix returned "oauth" for any \x01-prefixed key). + if (apiKey.startsWith(OAUTH_CTX_PREFIX)) { + const oauth = decodeOAuthCtx(apiKey); + return { userId: oauth ? sanitizeForLog(oauth.userId) : null, keyPrefix: "oauth" }; + } + if (apiKey.startsWith("cngrm_")) return { userId: null, keyPrefix: apiKey.slice(0, 10) }; + return { userId: null, keyPrefix: apiKey.slice(0, 4) }; } async function withErrorHandling( @@ -73,24 +141,42 @@ async function withErrorHandling( ) { const start = Date.now(); let success = true; - try { - return await fn(); - } catch (error) { - success = false; - const message = error instanceof Error ? error.message : String(error); - return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; - } finally { - process.stderr.write( - JSON.stringify({ - level: "info", - evt: "tool.call", - tool: toolName, - key_prefix: extractKeyPrefix(apiKey), - duration_ms: Date.now() - start, - success, - }) + "\n", - ); - } + const { userId, keyPrefix } = decodeAuthIdentity(apiKey); + // Per-call scope (NOT global Sentry.setUser): the process is shared across concurrent HTTP requests. + // withScope forks the current scope, kept per-call via the OTel async-context strategy, so + // captureException inside binds the right user even across awaits; withScope returns the callback's + // return value (incl. the Promise). + return await Sentry.withScope(async (scope) => { + const identity = userId ?? keyPrefix; + if (identity) scope.setUser({ id: identity }); + try { + return await fn(); + } catch (error) { + success = false; + Sentry.captureException(error, { tags: { tool: toolName, error_layer: "tool_execution" } }); + const message = error instanceof Error ? error.message : String(error); + return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; + } finally { + process.stderr.write( + JSON.stringify({ + level: "info", + evt: "tool.call", + tool: toolName, + key_prefix: keyPrefix, + user_id: userId ?? keyPrefix, + duration_ms: Date.now() - start, + success, + }) + "\n", + ); + } + }); +} + +// ── Optional tools flag ──────────────────────────────────────────── + +/** Whether tools outside the default set are registered. Read once, at registration time. */ +export function experimentalToolsEnabled(): boolean { + return process.env.CENOGRAM_EXPERIMENTAL_TOOLS === "1"; } // ── Tool registration ────────────────────────────────────────────── @@ -106,6 +192,8 @@ Returns transaction details: address, date, price, area, price/m², property typ Use list_locations first to find valid location names. Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN. Data notes: marketType is NULL for ~55% of records (notary didn't classify) - filtering by marketType excludes them. ~1.7% of records have no transaction_date. +Permalink: every result is shareable on the map. From a result's "id:" line and its "Location: °N, °E" line, build https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}#v=1&lat=&lng=&z=16&tx= (drop the °N/°E; lat = the °N number, lng = the °E number) — opens that exact transaction on the map. Omit &tx= for the area only. +Field provenance: values are from the notarial deed (RCN) by default; computed values (parcel area summed across plots or converted from hectares, an inferred/reclassified property type) and approximated streets are flagged inline with a neutral [...] note. Location matches TERYT districts only - for neighborhoods (osiedla), use search_by_area instead.`, { location: z.string().optional().describe( @@ -118,12 +206,26 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_ .describe("Property type filter"), marketType: z.enum(["primary", "secondary"]).optional() .describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."), - unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional() - .describe("Unit/apartment function filter"), - buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional() - .describe("Building type filter (PKOB classification)"), + unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional() + .describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."), + buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional() + .describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."), + ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional() + .describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."), mpzpDesignation: z.string().optional() - .describe("MPZP zoning designation filter (exact match, e.g. 'budownictwoMieszkanioweWielorodzinne', 'terenObiektowProdukcyjnychSkladowIMagazynow')"), + .describe("MPZP zoning designation filter (exact match, e.g. 'budownictwoMieszkanioweWielorodzinne', 'terenObiektowProdukcyjnychSkladowIMagazynow'). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ' (= 'no plan/WZ' recorded as data)."), + transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional() + .describe("Transaction type filter. For market analysis, ALWAYS specify transactionType to exclude non-market transactions (subsidized, foreclosure, public purpose). ~2% of transactions have unknown type (NULL) and are excluded when this filter is used unless 'unknown' is included."), + rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional() + .describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."), + floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional() + .describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."), + floodRisk: z.array(z.enum(["low", "medium", "high"])).optional() + .describe("Flood-hazard filter. high = most frequent flooding (~1-in-10-year), medium (~1-in-100-year), low = rarest (~1-in-500-year). Selects ONLY transactions whose land sits in a mapped flood zone; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['medium','high'] = at least medium risk."), + heritageStatus: z.array(z.enum(["listed", "zone"])).optional() + .describe("Heritage-listing filter. listed = a protected monument on/at the property's land; zone = the land lies within a protected urban layout or the designated surroundings of a monument. Selects ONLY transactions where a listing was detected; absence of a detection is never asserted as 'not listed'. Multi-select; e.g. ['listed'] = individually listed properties only."), + landslideRisk: z.array(z.enum(["landslide", "threatened"])).optional() + .describe("Landslide-hazard filter, from official landslide-hazard maps (1:10,000 scale). 'landslide' = the land intersects a mapped landslide area; 'threatened' = an area threatened by mass movements. Selects ONLY transactions whose land intersects a mapped hazard area — an intersection means overlap with a mapped area, not that the parcel itself is a landslide; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['landslide','threatened'] = any mapped hazard."), minPrice: z.number().optional().describe("Minimum price in PLN"), maxPrice: z.number().optional().describe("Maximum price in PLN"), dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"), @@ -142,7 +244,7 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_ page: z.number().min(1).default(1).optional() .describe("Page number for pagination (default: 1)"), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Search Real Estate Transactions" }, async (params) => withErrorHandling("search_transactions", apiKey, async () => { requireApiKey(apiKey); @@ -169,8 +271,15 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_ propertyType: mapPropertyType(params.propertyType), marketType: mapMarketType(params.marketType), unitFunction: mapUnitFunction(params.unitFunction), + ownershipType: mapOwnershipTypes(params.ownershipType), buildingType: mapBuildingType(params.buildingType), mpzpDesignation: params.mpzpDesignation, + transactionType: mapTransactionTypes(params.transactionType), + rooms: params.rooms?.join(","), + floor: params.floor?.join(","), + floodRisk: params.floodRisk?.join(","), + heritageStatus: params.heritageStatus?.join(","), + landslideRisk: params.landslideRisk?.join(","), minPrice: params.minPrice, maxPrice: params.maxPrice, dateFrom: params.dateFrom, @@ -200,26 +309,38 @@ server.tool( `Get price per m² statistics by location for residential apartments in Poland. Note: only covers residential units (lokale mieszkalne). For other property types, use search_transactions. 'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts (Warszawa=19, Kraków=5, Łódź=6). Other names use partial match. -Data quality: based on transaction prices from notarial deeds, not asking/listing prices. Coverage varies by county (some have data gaps of 5+ years).`, +Data quality: based on transaction prices from notarial deeds, not asking/listing prices. Coverage varies by county (some have data gaps of 5+ years). +${MARKET_CAVEAT}`, { location: z.string().optional().describe( "Filter by location name. 'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts. Other names use case-insensitive partial match (e.g. 'Wrocł' matches 'Wrocław'). Omit for all Poland.", ), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Price per m² Statistics" }, async (params) => withErrorHandling("get_price_statistics", apiKey, async () => { requireApiKey(apiKey); const { data: allRows, creditInfo } = await getPricePerM2(apiKey); let rows = allRows; if (params.location) { - if (CITY_SUBDISTRICTS.has(params.location)) { - const allowed = new Set(expandDistrict(params.location)); + // City keys (Warszawa/Kraków/Łódź) resolve from the static + // sub-district map — skip the /api/districts fetch. getPricePerM2 still runs. + const city = tryResolveCityKey(params.location); + if (city) { + const allowed = new Set(city); rows = rows.filter((r) => allowed.has(r.district)); } else { - rows = rows.filter((r) => - filterByLocation(params.location!, [r.district]).length > 0, - ); + const { data: allDistricts } = await getDistricts(apiKey); + const resolved = resolveDistrict(params.location, allDistricts); + const isCityExpansion = resolved.length > 1; + if (isCityExpansion) { + const allowed = new Set(resolved); + rows = rows.filter((r) => allowed.has(r.district)); + } else { + rows = rows.filter((r) => + filterByLocation(params.location!, [r.district]).length > 0, + ); + } } } return textResponse(formatPriceStats(rows, params.location) + formatCreditFooter(creditInfo)); @@ -231,14 +352,15 @@ Data quality: based on transaction prices from notarial deeds, not asking/listin server.tool( "get_price_distribution", `Get price distribution histogram showing how many transactions fall into each price range. -Useful for understanding the overall market price structure in Poland.`, +Useful for understanding the overall market price structure in Poland. +${MARKET_CAVEAT}`, { bins: z.number().min(5).max(50).default(20) .describe("Number of price bins (5-50, default 20)"), maxPrice: z.number().default(3_000_000) .describe("Maximum price to include (default 3,000,000 PLN)"), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Price Distribution Histogram" }, async (params) => withErrorHandling("get_price_distribution", apiKey, async () => { requireApiKey(apiKey); @@ -255,7 +377,9 @@ server.tool( Best tool for neighborhood/osiedle searches (neighborhoods are not TERYT districts). Radius guide: 0.3-0.5 km for a street, 0.5-1 km for a neighborhood, 2-5 km for a city area. Example: apartments in Wrocław's Nowy Dwór (lat 51.143, lng 16.993, radiusKm=0.7). -Area filters (minArea/maxArea) work for all propertyType values.`, +Area filters (minArea/maxArea) work for all propertyType values. +Permalink: every result is shareable on the map. From a result's "id:" line and its "Location: °N, °E" line, build https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}#v=1&lat=&lng=&z=16&tx= (drop the °N/°E; lat = the °N number, lng = the °E number) — opens that exact transaction on the map. Omit &tx= for the area only. +Field provenance: values are from the notarial deed (RCN) by default; computed values (parcel area summed across plots or converted from hectares, an inferred/reclassified property type) and approximated streets are flagged inline with a neutral [...] note.`, { latitude: z.number().min(49).max(55) .describe("Latitude (Poland range: 49-55)"), @@ -267,10 +391,12 @@ Area filters (minArea/maxArea) work for all propertyType values.`, .describe("Property type filter"), marketType: z.enum(["primary", "secondary"]).optional() .describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."), - unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional() - .describe("Unit/apartment function filter"), - buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional() - .describe("Building type filter (PKOB classification)"), + unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional() + .describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."), + buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional() + .describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."), + ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional() + .describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."), minPrice: z.number().optional().describe("Minimum price in PLN"), maxPrice: z.number().optional().describe("Maximum price in PLN"), minArea: z.number().optional() @@ -279,10 +405,22 @@ Area filters (minArea/maxArea) work for all propertyType values.`, .describe("Maximum area in m²"), dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"), dateTo: z.string().optional().describe("End date (YYYY-MM-DD)"), + transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional() + .describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."), + rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional() + .describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."), + floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional() + .describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."), + floodRisk: z.array(z.enum(["low", "medium", "high"])).optional() + .describe("Flood-hazard filter. high = most frequent flooding (~1-in-10-year), medium (~1-in-100-year), low = rarest (~1-in-500-year). Selects ONLY transactions whose land sits in a mapped flood zone; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['medium','high'] = at least medium risk."), + heritageStatus: z.array(z.enum(["listed", "zone"])).optional() + .describe("Heritage-listing filter. listed = a protected monument on/at the property's land; zone = the land lies within a protected urban layout or the designated surroundings of a monument. Selects ONLY transactions where a listing was detected; absence of a detection is never asserted as 'not listed'. Multi-select; e.g. ['listed'] = individually listed properties only."), + landslideRisk: z.array(z.enum(["landslide", "threatened"])).optional() + .describe("Landslide-hazard filter, from official landslide-hazard maps (1:10,000 scale). 'landslide' = the land intersects a mapped landslide area; 'threatened' = an area threatened by mass movements. Selects ONLY transactions whose land intersects a mapped hazard area — an intersection means overlap with a mapped area, not that the parcel itself is a landslide; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['landslide','threatened'] = any mapped hazard."), limit: z.number().min(1).max(50).default(20) .describe("Number of results (1-50, default 20)"), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Radius" }, async (params) => withErrorHandling("search_by_area", apiKey, async () => { requireApiKey(apiKey); @@ -292,7 +430,14 @@ Area filters (minArea/maxArea) work for all propertyType values.`, propertyType: mapPropertyType(params.propertyType), marketType: mapMarketType(params.marketType), unitFunction: mapUnitFunction(params.unitFunction), + ownershipType: mapOwnershipTypes(params.ownershipType), buildingType: mapBuildingType(params.buildingType), + transactionType: mapTransactionTypes(params.transactionType), + rooms: params.rooms?.join(","), + floor: params.floor?.join(","), + floodRisk: params.floodRisk?.join(","), + heritageStatus: params.heritageStatus?.join(","), + landslideRisk: params.landslideRisk?.join(","), minPrice: params.minPrice, maxPrice: params.maxPrice, minArea: params.minArea, @@ -317,9 +462,10 @@ server.tool( "get_market_overview", `Get a comprehensive overview of the Polish real estate transaction database. Returns: total transaction count, date range, breakdown by property type and market type, top locations, price statistics. -Note: data quality varies by field - marketType is unknown for ~55% of records, transaction_date missing for ~1.7%.`, +Note: data quality varies by field - marketType is unknown for ~55% of records, transaction_date missing for ~1.7%. +${MARKET_CAVEAT}`, {}, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Market Overview" }, async () => withErrorHandling("get_market_overview", apiKey, async () => { requireApiKey(apiKey); @@ -347,7 +493,7 @@ Use 'location' for quick city searches, 'teryt' for precise administrative filte "Filter locations by name (case-insensitive partial match, e.g. 'Krak' for Kraków districts). Ignored when parent is set.", ), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "List Locations & TERYT Codes" }, async (params) => withErrorHandling("list_locations", apiKey, async () => { requireApiKey(apiKey); @@ -369,10 +515,20 @@ Use 'location' for quick city searches, 'teryt' for precise administrative filte return textResponse(formatLocationHierarchy(locations) + formatCreditFooter(creditInfo)); } - const { data: allDistricts, creditInfo } = await getDistricts(apiKey); - let districts = allDistricts; - if (params.search) { - districts = filterByLocation(params.search, districts); + // For known city keys (Warszawa/Kraków/Łódź) skip /api/districts + // entirely — sub-districts come from the static map, zero API call (and zero credit). + // params.search is a defined non-empty string here (undefined handled by the early + // return above; zod enforces .min(1)). + let districts: string[]; + let creditInfo: CreditInfo | null; + const city = tryResolveCityKey(params.search); + if (city) { + districts = city; + creditInfo = null; + } else { + const res = await getDistricts(apiKey); + creditInfo = res.creditInfo; + districts = filterByLocation(params.search, res.data); } if (districts.length === 0) { const msg = params.search @@ -407,7 +563,7 @@ Example: search for parcels starting with '146518_8.01'.`, limit: z.number().min(1).max(10).default(10).optional() .describe("Max results (1-10, default 10)"), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Search Land Parcels" }, async (params) => withErrorHandling("search_parcels", apiKey, async () => { requireApiKey(apiKey); @@ -416,6 +572,83 @@ Example: search for parcels starting with '146518_8.01'.`, }), ); +// ── Tool: resolve_parcel ──────────────────────────────────────────── + +server.tool( + "resolve_parcel", + `Resolve a land parcel to its cadastral identity using exactly ONE of: +- parcelId: a full cadastral id, either raw '/' form ('142907_2.0014.342/5') or URL-safe '-' form ('142907_2.0014.342-5'), or the internal UUID from search results. +- q: a full cadastral id, a UUID, OR free-text 'locality name + parcel number' (e.g. 'Sabnie 342/5'). Name matching is exact on the locality (case-insensitive) — an unusual spelling may miss. +- lat & lng: a WGS84 point inside the parcel (returns the parcel(s) containing that point). +Returns a list of matching parcels with district, area, and coordinates; 'truncated' when the name+number match was capped. When nothing matches, coverage is not_covered and the credit is refunded. +Use this to turn an address point, a coordinate, or a locality+number into a concrete parcel id — then feed that id to search_transactions (parcelId) to see its sale history. +Costs 1 API token (refunded when nothing matches).`, + { + q: z.string().max(200).optional().describe( + "Full cadastral id, a UUID, or 'locality name + parcel number' (e.g. 'Sabnie 342/5'). Mutually exclusive with parcelId and lat/lng.", + ), + parcelId: z.string().max(200).optional().describe( + "Full cadastral id (slash or dash form) or internal UUID. Mutually exclusive with q and lat/lng.", + ), + lat: z.number().min(-90).max(90).optional().describe( + "Latitude WGS84. Must be paired with lng. Mutually exclusive with q and parcelId.", + ), + lng: z.number().min(-180).max(180).optional().describe( + "Longitude WGS84. Must be paired with lat. Mutually exclusive with q and parcelId.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Resolve Land Parcel" }, + async (params) => + withErrorHandling("resolve_parcel", apiKey, async () => { + requireApiKey(apiKey); + // Exactly-one-mode guard (mirrors the server's 400). The MCP SDK's tool() takes a raw Zod shape + // (each field validated independently) and offers no object-level .refine for cross-field rules — + // same limitation search_by_polygon works around with a field-level refine — so the exclusivity is + // enforced here, pre-flight, to give a clear message without spending a call/credit. + const hasQ = params.q != null && params.q !== ""; + const hasParcelId = params.parcelId != null && params.parcelId !== ""; + const hasLat = params.lat != null; + const hasLng = params.lng != null; + const modeCount = (hasQ ? 1 : 0) + (hasParcelId ? 1 : 0) + (hasLat || hasLng ? 1 : 0); + if (modeCount !== 1) { + return textResponse( + "Provide exactly one lookup mode: q=, parcelId=, or lat= and lng=.", + ); + } + if ((hasLat || hasLng) && !(hasLat && hasLng)) { + return textResponse("lat and lng must be provided together."); + } + const { data, creditInfo } = await resolveParcel( + { q: params.q, parcelId: params.parcelId, lat: params.lat, lng: params.lng }, + apiKey, + ); + return textResponse(formatParcelResolve(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: get_parcel_report ───────────────────────────────────────── + +server.tool( + "get_parcel_report", + `The whole dossier for one land parcel in a single call: the parcel core (location, area, land use, plan designation), all nine enrichment layers (flood risk, heritage listing, landslide risk, nuisance surroundings, public-transport access, general-plan zoning, buildings on the parcel, recent building activity, agricultural-land eligibility), the parcel's transaction history (newest first, up to 20), a local price context (median zł/m² for the county and the locality over the last 12 months) and a municipal context (a headline demographic/economic subset plus upcoming-infrastructure signals for the gmina). +Address it by a full cadastral id in the natural '/' form ('142907_2.0014.342/5'), the URL-safe '-' form, or the internal UUID from a search or resolve result. +Each section carries its own state, shown explicitly: covered = a definitive result; covered_no_data = the parcel was checked and nothing was found (still billed); not_covered = outside our data (refunded); not_computed = a live computation could not finish in time (refunded — the rest of the report still returns, so a report can be partial). The two context sections instead use full / low_sample / suppressed / no_data. +Prefer this over calling the per-layer parcel tools one by one — it is one call at a flat price and never costs more than the sum of its parts. Use resolve_parcel first when you only have an address, a coordinate, or a 'locality + number'. +Costs 35 API tokens. Billing is by outcome (see the billing line on the response): a parcel that cannot be resolved is fully refunded; a resolved parcel where no layer had data is billed only the core floor (1 token) with the rest refunded; a resolved parcel with at least one covered layer is billed in full.`, + { + parcelId: z.string().min(3).max(200).describe( + "Full cadastral id ('142907_2.0014.342/5' or the '-' form) or the internal UUID from a search/resolve result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Get Parcel Report" }, + async (params) => + withErrorHandling("get_parcel_report", apiKey, async () => { + requireApiKey(apiKey); + const { data, creditInfo } = await getParcelReport(params.parcelId, apiKey); + return textResponse(formatParcelReport(data) + formatCreditFooter(creditInfo)); + }), +); + // ── Tool 8: search_by_polygon ────────────────────────────────────── server.tool( @@ -425,22 +658,36 @@ Provide a GeoJSON Polygon geometry to search within a custom area. Returns transactions found inside the polygon with coordinates. Use for precise neighborhood/osiedle boundaries. Can estimate coordinates from search_by_area results. For quick searches, start with search_by_area instead. Coordinates are [longitude, latitude]. First and last point must be identical. +Permalink: every result is shareable on the map. From a result's "id:" line and its "Location: °N, °E" line, build https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}#v=1&lat=&lng=&z=16&tx= (drop the °N/°E; lat = the °N number, lng = the °E number) — opens that exact transaction on the map. Omit &tx= for the area only. +Field provenance: values are from the notarial deed (RCN) by default; computed values (parcel area summed across plots or converted from hectares, an inferred/reclassified property type) and approximated streets are flagged inline with a neutral [...] note. Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21],[21.0,52.21],[21.0,52.2]]]}`, { polygon: z.object({ type: z.literal("Polygon"), - coordinates: z.array(z.array(z.array(z.number()))), - }).describe("GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs. Max 500 vertices."), + coordinates: z.array(z.array(z.array(z.number()))).min(1), + }).refine( + // Mirror of the server-side guard - keep in sync + (poly) => poly.coordinates.reduce((sum, ring) => sum + ring.length, 0) <= 500, + { message: "polygon exceeds 500 total vertices (sum across all rings)" }, + ).describe("GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs. First and last point must be identical. Max 500 vertices total."), propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional() .describe("Property type filter"), marketType: z.enum(["primary", "secondary"]).optional() .describe("Market type filter"), - unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional() - .describe("Unit/apartment function filter"), - buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional() - .describe("Building type filter (PKOB classification)"), + unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional() + .describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."), + buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional() + .describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."), + ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional() + .describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."), mpzpDesignation: z.string().optional() - .describe("MPZP zoning designation filter (exact match)"), + .describe("MPZP zoning designation filter (exact match). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ'."), + transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional() + .describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."), + rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional() + .describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."), + floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional() + .describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."), minPrice: z.number().optional().describe("Minimum price in PLN"), maxPrice: z.number().optional().describe("Maximum price in PLN"), dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"), @@ -452,7 +699,7 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21 limit: z.number().min(1).max(5000).default(100).optional() .describe("Max results (1-5000, default 100). MCP displays up to 50 transactions."), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Polygon" }, async (params) => withErrorHandling("search_by_polygon", apiKey, async () => { requireApiKey(apiKey); @@ -461,8 +708,12 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21 propertyType: mapPropertyType(params.propertyType), marketType: mapMarketType(params.marketType), unitFunction: mapUnitFunction(params.unitFunction), + ownershipType: mapOwnershipTypes(params.ownershipType), buildingType: mapBuildingType(params.buildingType), mpzpDesignation: params.mpzpDesignation, + transactionType: mapTransactionTypes(params.transactionType), + rooms: params.rooms?.join(","), + floor: params.floor?.join(","), minPrice: params.minPrice, maxPrice: params.maxPrice, dateFrom: params.dateFrom, @@ -485,21 +736,33 @@ server.tool( Provide 2-5 district names to compare median price/m², average area, and transaction counts. Use list_locations first to find valid location names. Requires at least one filter besides districts (e.g., propertyType). -Example: compare Mokotów, Wola, Ursynów for apartments.`, +Example: compare Mokotów, Wola, Ursynów for apartments. +${MARKET_CAVEAT}`, { - districts: z.string().min(1).describe( - "Comma-separated district names to compare (2-5). E.g. 'Mokotów,Wola,Ursynów'", - ), + districts: z.string() + // Mirror of the server-side guard - server dedupes then enforces 1..5; MCP requires 2..5 for compare semantics + .refine( + (s) => { + const list = [...new Set(s.split(",").map((d) => d.trim()).filter(Boolean))]; + return list.length >= 2 && list.length <= 5; + }, + { message: "districts must be 2-5 unique comma-separated names (e.g. 'Mokotów,Wola,Ursynów')" }, + ) + .describe("Comma-separated district names to compare (2-5, must be unique). E.g. 'Mokotów,Wola,Ursynów'"), propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional() .describe("Property type filter (recommended - API requires at least one filter)"), marketType: z.enum(["primary", "secondary"]).optional() .describe("Market type filter"), - unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional() - .describe("Unit/apartment function filter"), - buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional() - .describe("Building type filter (PKOB classification)"), + unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional() + .describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."), + buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional() + .describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."), + ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional() + .describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."), mpzpDesignation: z.string().optional() - .describe("MPZP zoning designation prefix filter (e.g. 'terenRolniczy', 'budownictwoMieszkanioweJednorodzinne', 'budownictwoMieszkanioweWielorodzinne')"), + .describe("MPZP zoning designation prefix filter (e.g. 'terenRolniczy', 'budownictwoMieszkanioweJednorodzinne', 'budownictwoMieszkanioweWielorodzinne'). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ'."), + transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional() + .describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."), minPrice: z.number().optional().describe("Minimum price in PLN"), maxPrice: z.number().optional().describe("Maximum price in PLN"), dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"), @@ -507,18 +770,53 @@ Example: compare Mokotów, Wola, Ursynów for apartments.`, minArea: z.number().optional().describe("Minimum area in m²"), maxArea: z.number().optional().describe("Maximum area in m²"), street: z.string().optional().describe("Street name filter"), + rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional() + .describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."), + floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional() + .describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."), + includeDemographics: z.boolean().optional() + .describe("Add a GUS BDL demographics block per district (county-level: population density, wages, unemployment, median age, plus a few cross-source ratios like price-to-income). Districts that don't resolve to a county are omitted from the demographics section."), }, - { readOnlyHint: true }, + { readOnlyHint: true, destructiveHint: false, title: "Compare Locations" }, async (params) => withErrorHandling("compare_locations", apiKey, async () => { requireApiKey(apiKey); + // Mirror of the server-side guard - at least one filter required besides districts. + // Free-text strings use .trim() (server's safeString treats "" and whitespace-only as missing). + // Enums are validated by zod first, so "" / " " never reach here. + const hasFilter = + !!params.propertyType || + !!params.marketType || + !!params.unitFunction || + !!params.buildingType || + !!params.mpzpDesignation?.trim() || + (params.transactionType != null && params.transactionType.length > 0) || + (params.rooms != null && params.rooms.length > 0) || + (params.floor != null && params.floor.length > 0) || + (params.ownershipType != null && params.ownershipType.length > 0) || + params.minPrice != null || + params.maxPrice != null || + !!params.dateFrom?.trim() || + !!params.dateTo?.trim() || + params.minArea != null || + params.maxArea != null || + !!params.street?.trim(); + if (!hasFilter) { + return textResponse( + "compare_locations requires at least one filter besides districts (e.g. propertyType=unit, marketType=secondary, or a date range).", + ); + } const { data, creditInfo } = await compareLocations({ districts: params.districts, propertyType: mapPropertyType(params.propertyType), marketType: mapMarketType(params.marketType), unitFunction: mapUnitFunction(params.unitFunction), + ownershipType: mapOwnershipTypes(params.ownershipType), buildingType: mapBuildingType(params.buildingType), mpzpDesignation: params.mpzpDesignation, + transactionType: mapTransactionTypes(params.transactionType), + rooms: params.rooms?.join(","), + floor: params.floor?.join(","), minPrice: params.minPrice, maxPrice: params.maxPrice, dateFrom: params.dateFrom, @@ -526,9 +824,527 @@ Example: compare Mokotów, Wola, Ursynów for apartments.`, minArea: params.minArea, maxArea: params.maxArea, street: params.street, + // Comma-separated on the wire, per the API's list-param convention — but a boolean is clearer + // for an LLM; forward-compat: a future + // includeAsking would join with a comma. Demo mode (REST) silently drops enrichment. + include: params.includeDemographics ? "demographics" : undefined, }, apiKey); return textResponse(formatCompareResults(data) + formatCreditFooter(creditInfo)); }), ); +// ── Tool: get_demographics (PUBLIC) ───────────────────────────────── + +// Format guard only (NOT resolution — that lives in REST): 2/4/6/7-digit TERYT. +const DEMOGRAPHICS_TERYT_RE = /^(\d{2}|\d{4}|\d{6,7})$/; +// Mirror of the server-side category enum. Kept inline to avoid a cross-package dependency; if the +// server list changes, an unknown category simply 400s server-side. +const DEMOGRAPHICS_CATEGORIES = [ + "demographics", "economy", "economy_macro", "housing", "planning", + "infrastructure", "environment", "safety", "re_market", "education", "prices", +] as const; + +server.tool( + "get_demographics", + `Demographic, economic, housing and other local statistics for a Polish location, from GUS BDL (Bank Danych Lokalnych) — Poland's public Central Statistical Office open-data bank. ~50 indicators across 11 categories (population, economy, housing, spatial planning, infrastructure, environment, safety, education, prices) plus a few derived metrics. +Address by location (city/county name) OR teryt. A name resolves to county/powiat (4-digit) level; for richer gmina/district-level data (L6) pass a 6 or 7-digit teryt. teryt wins when both are given. Use list_locations to find TERYT codes — neighborhoods/osiedla are NOT addressable here. +A query returns the requested level PLUS all parent levels (a gmina query also yields powiat, NUTS3 region and voivodeship indicators). Optional year, or yearFrom+yearTo for a time series, and category to filter. Cost: 1 token.`, + { + location: z.string().optional().describe( + "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.", + ), + teryt: z.string().optional().describe( + "TERYT code: 2-digit (voivodeship, e.g. 14), 4-digit (county, e.g. 1465), 6 or 7-digit (gmina, e.g. 1465011). Wins over location. Use list_locations to find codes.", + ), + year: z.number().int().optional().describe( + "Single year (2003-present). Mutually exclusive with yearFrom/yearTo. Omit for the latest available year per indicator.", + ), + yearFrom: z.number().int().optional().describe("Start year for a time series (min 2003)."), + yearTo: z.number().int().optional().describe("End year for a time series (max current year + 1)."), + category: z.array(z.enum(DEMOGRAPHICS_CATEGORIES)).optional().describe( + "Filter to these categories. Omit to return all available.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Demographics & Local Statistics" }, + async (params) => + withErrorHandling("get_demographics", apiKey, async () => { + requireApiKey(apiKey); + const location = params.location?.trim(); + const teryt = params.teryt?.trim(); + if (!location && !teryt) { + return textResponse( + 'Provide a location (city/county name) or teryt (administrative code). Example: get_demographics(location="Warszawa").', + ); + } + // Obvious format error → reject before the API call (saves the 1-credit charge). A name that + // doesn't resolve (e.g. a neighborhood) is left to REST → 404 → auto-refunded by the global hook. + if (teryt && !DEMOGRAPHICS_TERYT_RE.test(teryt)) { + return textResponse( + `Invalid teryt '${sanitizeInput(teryt)}'. Use 2 digits (voivodeship), 4 (county), or 6-7 (gmina). Use list_locations to find codes.`, + ); + } + const { data, creditInfo } = await getDemographics( + { + location, + teryt, + year: params.year, + yearFrom: params.yearFrom, + yearTo: params.yearTo, + category: params.category?.join(","), + }, + apiKey, + ); + return textResponse(formatDemographics(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: get_infrastructure_signals (PUBLIC) ─────────────────────── + +// Format guard only (resolution lives in REST): 4-digit county or 6/7-digit municipality. +const INFRA_TERYT_RE = /^(\d{4}|\d{6,7})$/; + +server.tool( + "get_infrastructure_signals", + `Signals that a Polish municipality is about to build infrastructure — sewerage, water supply, roads, street lighting, gas network or cycling infrastructure. Three independent public sources: tenders published in the national public procurement bulletin (rolling 12-month window), membership in an agglomeration of the national urban waste-water treatment programme (where collective sewerage exists or is planned), and the municipality's own planned capital expenditure from its multi-year financial forecast. +Address by location (city/county name → aggregates every municipality in that county) OR teryt (6-7 digits = one municipality, 4 digits = a county aggregate). teryt wins when both are given. Use list_locations to find codes. +Known limits, state them when you report results: the bulletin carries only contracts BELOW the EU procurement thresholds (from 2021), so the largest investments are not visible here. A tender is attributed to the SEAT of the contracting authority, not to the works location — county and national authorities tender works in other municipalities. The category counters therefore include municipal authorities only, while the recent-notice list shows every authority with a flag. Absence of tenders is NOT evidence that a municipality is not investing. +Cost: 1 token.`, + { + location: z.string().optional().describe( + "City or county name (e.g. 'Warszawa', 'Krotoszyn'). Aggregates every municipality in the county. Use this OR teryt.", + ), + teryt: z.string().optional().describe( + "TERYT code: 6 or 7 digits = one municipality (e.g. 146501), 4 digits = a county aggregate (e.g. 1465). Wins over location.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Infrastructure Signals" }, + async (params) => + withErrorHandling("get_infrastructure_signals", apiKey, async () => { + requireApiKey(apiKey); + const location = params.location?.trim(); + const teryt = params.teryt?.trim(); + if (!location && !teryt) { + return textResponse( + 'Provide a location (city/county name) or teryt (administrative code). Example: get_infrastructure_signals(location="Krotoszyn").', + ); + } + // Obvious format error → reject before the API call (saves the 1-credit charge). A code that + // is well-formed but nonexistent is left to REST → 404 → auto-refunded by the global hook. + if (teryt && !INFRA_TERYT_RE.test(teryt)) { + return textResponse( + `Invalid teryt '${sanitizeInput(teryt)}'. Use 4 digits (county) or 6-7 digits (municipality). Use list_locations to find codes.`, + ); + } + const { data, creditInfo } = await getInfrastructureSignals({ location, teryt }, apiKey); + return textResponse(formatInfrastructureSignals(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: estimate_value ──────────────────────────────────────────── +// Part of the default tool set, flagged "[Beta]" in the description. + +server.tool( + "estimate_value", + `[Beta] Estimate the market value of an apartment from comparable registered transaction prices near a point. An orientation estimate, NOT a certified appraisal (operat szacunkowy) — it does not account for the unit's condition, finish standard or floor, and does not replace a surveyor's valuation. +Address by lat + lng (a point on the map) OR parcelId (a full cadastral id or internal UUID; the parcel centroid is used) — exactly one. area (usable area in m², 10–250) is REQUIRED: there is no per-address floor-area source in Poland, so the caller supplies it. +Optional: rooms (1–10) and market (primary/secondary) narrow the comparables; includeComps (default true) echoes the nearest comparables it weighed. +Returns the point estimate, a likely and a wide value range, a confidence band, the comparable count, and an as_of date. as_of reflects transaction-data freshness, which lags by county — estimates are NOT directly comparable across cities with different as_of. +Apartments only (v1), 10–250 m². Too few comparables near the point → no estimate (the credit is refunded). Costs 5 API tokens, refunded when no estimate is produced. ${MARKET_CAVEAT}`, + { + // Bounds are the Poland bbox, same as search_by_area — the data is Polish, and a point far outside it + // only buys a slow round-trip that ends in "no estimate". + lat: z.number().min(49).max(55).optional().describe( + "Latitude of the apartment (WGS84, Poland). Must be paired with lng. Use this OR parcelId.", + ), + lng: z.number().min(14).max(25).optional().describe( + "Longitude of the apartment (WGS84, Poland). Must be paired with lat. Use this OR parcelId.", + ), + parcelId: z.string().max(200).optional().describe( + "Full cadastral id (slash or dash form) or internal UUID; the parcel centroid is used. Use instead of lat/lng.", + ), + area: z.number().min(10).max(250).describe( + "Apartment usable area in m² (REQUIRED, 10–250). Estimates for 300+ m² are unreliable and rejected.", + ), + rooms: z.number().int().min(1).max(10).optional().describe( + "Room count (1–10, optional) — narrows the comparables to ±1 room.", + ), + market: z.enum(["primary", "secondary"]).optional().describe( + "Restrict comparables to the primary (new-build) or secondary market (optional).", + ), + includeComps: z.boolean().optional().describe( + "Echo the nearest comparables the estimate weighed (default true). Set false for the estimate only.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "[Beta] Apartment Value Estimate" }, + async (params) => + withErrorHandling("estimate_value", apiKey, async () => { + requireApiKey(apiKey); + // Exactly-one-mode guard (mirrors resolve_parcel + the server's 400). The MCP SDK's tool() takes a + // raw Zod shape with no object-level .refine, so enforce lat/lng-XOR-parcelId here, pre-flight, to + // return a clear message without spending a call/credit. + const hasLat = params.lat != null; + const hasLng = params.lng != null; + const hasParcel = params.parcelId != null && params.parcelId !== ""; + const latLngMode = hasLat || hasLng; + const modeCount = (latLngMode ? 1 : 0) + (hasParcel ? 1 : 0); + if (modeCount !== 1) { + return textResponse("Provide exactly one location: lat= and lng=, OR parcelId=."); + } + if (latLngMode && !(hasLat && hasLng)) { + return textResponse("lat and lng must be provided together."); + } + const { data, creditInfo } = await getValuation( + { + lat: params.lat, + lng: params.lng, + parcelId: params.parcelId, + area: params.area, + rooms: params.rooms, + market: params.market, + includeComps: params.includeComps ?? true, + }, + apiKey, + ); + return textResponse(formatValuation(data) + formatCreditFooter(creditInfo)); + }), +); + +// Tools outside the default set (off unless enabled). +if (experimentalToolsEnabled()) { + +// ── Tool: get_rental_yield ────────────────────────────────────────── + +server.tool( + "get_rental_yield", + `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it. +Estimate the gross rental yield for a Polish city or county: annualized median asking rent (PLN/m²/month × 12) divided by the median apartment transaction price per m² (secondary market) from the RCN registry. +Gross and top-line only — excludes vacancy, management, tax and maintenance. Indicative, not investment advice. +Address by location (city name → resolves to a county) OR teryt (4-digit county code; 6-digit = dzielnica where available, today Warszawa's 18 districts, otherwise truncated to the county; teryt wins when both are given). Both sides need at least 5 samples or the result is suppressed. +Not comparable across cities with different as_of dates (RCN publication lag varies by county). Rent and transaction prices come from different sources, so the yield is an approximation. +Coverage is county-level only (miasta na prawach powiatu) plus Warszawa's 18 districts, and further limited to cities with asking-rent data. A town inside a larger powiat (e.g. Sandomierz, Pruszków), a non-Warszawa city district, or an osiedle does NOT resolve and returns a 404 — do not pass such names. Unless the location is a major city you already know is covered, call list_rental_yield_locations FIRST to get valid names, or pass a 4-digit county TERYT. +Optional areaBucket restricts both sides to an apartment area range in m2 (e.g. '40-50'). Area ranges are NOT additive — a bucket does not sum back to 'all'. +The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, + { + location: z.string().optional().describe( + "County-level city name — must be a miasto na prawach powiatu or a catalog entry from list_rental_yield_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A town within a larger powiat, a non-Warszawa district, or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt.", + ), + teryt: z.string().optional().describe( + "TERYT code. 4 digits = county (e.g. 1465 = Warszawa). 6 digits = dzielnica where available (today: Warszawa's 18 districts, e.g. 146510 = Śródmieście) → yield for that district. Other longer codes (gmina/precinct) are truncated to the county. Wins over location when both are provided.", + ), + areaBucket: z.enum(["all", "0-30", "30-40", "40-50", "50-60", "60-80", "80+"]).optional().describe( + "Apartment area range in m2: all (default, whole stock), 0-30, 30-40, 40-50, 50-60, 60-80, 80+. Bucket values are not additive.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "[Beta] Rental Yield Estimate" }, + async (params) => + withErrorHandling("get_rental_yield", apiKey, async () => { + requireApiKey(apiKey); + if (!params.location?.trim() && !params.teryt?.trim()) { + return textResponse( + 'Provide a location (city name) or teryt (county code). Example: get_rental_yield(location="Warszawa").', + ); + } + const { data, creditInfo } = await getRentalYield( + { location: params.location, teryt: params.teryt, areaBucket: params.areaBucket }, + apiKey, + ); + return textResponse(formatRentalYield(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: list_rental_yield_locations ─────────────────────────────── + +server.tool( + "list_rental_yield_locations", + `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it. +List the cities/counties for which get_rental_yield can return data (asking-rent coverage). Use this to discover valid location/teryt values for get_rental_yield instead of guessing names. +Each entry is coverage signal only (offer sample size + confidence) — it does not compute the yield; call get_rental_yield(location|teryt) for the actual yield. +Optional search filters by city name (diacritic-insensitive substring, min 2 chars). Results are sorted by rent_sample_n descending. Free (0 credits).`, + { + search: z.string().min(2, "search must be at least 2 characters").optional().describe( + "Filter by city name (diacritic-insensitive substring, min 2 chars). E.g. 'gda' → Gdańsk. Omit to list the full catalog.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "[Beta] List Rental Yield Locations" }, + async (params) => + withErrorHandling("list_rental_yield_locations", apiKey, async () => { + requireApiKey(apiKey); + const { data, creditInfo } = await getRentalYieldLocations( + { search: params.search }, + apiKey, + ); + return textResponse(formatRentalYieldLocations(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: get_price_spread ────────────────────────────────────────── + +server.tool( + "get_price_spread", + `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it. +Measure the asking-vs-transaction price spread for a Polish city or county: how far the median asking price per m² of apartments for sale sits above (or below) the median apartment transaction price per m² from the RCN registry. spread_pct = (asking − transaction) / transaction × 100. +The spread can be NEGATIVE (asking below transaction) in premium-secondary cities — that is a valid answer, not an error. +Address by location (city name → resolves to a county) OR teryt (4-digit county code; 6-digit = dzielnica where available, today Warszawa's 18 districts, otherwise truncated to the county; teryt wins when both are given). Both sides need at least 5 samples or the result is suppressed. +For marketType='all' (the default), sale offers are a mix of primary and secondary market, so the transaction denominator covers the whole market. With marketType='secondary' or 'primary', both the asking and transaction sides are narrowed to that single market segment. +Not comparable across cities with different as_of dates (RCN publication lag varies by county). Asking and transaction prices come from different sources, so the spread is an approximation. +Coverage is county-level only (miasta na prawach powiatu) plus Warszawa's 18 districts, and further limited to cities with asking-sale data. A town inside a larger powiat (e.g. Sandomierz, Pruszków), a non-Warszawa city district, or an osiedle does NOT resolve and returns a 404 — do not pass such names. Unless the location is a major city you already know is covered, call list_price_spread_locations FIRST to get valid names, or pass a 4-digit county TERYT. +Optional areaBucket restricts both sides to an apartment area range in m2 (e.g. '40-50'). Area ranges are NOT additive — a bucket does not sum back to 'all'. +The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, + { + location: z.string().optional().describe( + "County-level city name — must be a miasto na prawach powiatu or a catalog entry from list_price_spread_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A town within a larger powiat, a non-Warszawa district, or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt.", + ), + teryt: z.string().optional().describe( + "TERYT code. 4 digits = county (e.g. 1465 = Warszawa). 6 digits = dzielnica where available (today: Warszawa's 18 districts, e.g. 146510 = Śródmieście) → spread for that district. Other longer codes (gmina/precinct) are truncated to the county. Wins over location when both are provided.", + ), + marketType: z.enum(["primary", "secondary", "all"]).optional().describe( + "Transaction denominator segment: 'all' (default, composition-matched to mixed sale offers), 'secondary', or 'primary'.", + ), + areaBucket: z.enum(["all", "0-30", "30-40", "40-50", "50-60", "60-80", "80+"]).optional().describe( + "Apartment area range in m2: all (default, whole stock), 0-30, 30-40, 40-50, 50-60, 60-80, 80+. Bucket values are not additive.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "[Beta] Asking vs Transaction Price Spread" }, + async (params) => + withErrorHandling("get_price_spread", apiKey, async () => { + requireApiKey(apiKey); + if (!params.location?.trim() && !params.teryt?.trim()) { + return textResponse( + 'Provide a location (city name) or teryt (county code). Example: get_price_spread(location="Warszawa").', + ); + } + const { data, creditInfo } = await getPriceSpread( + { location: params.location, teryt: params.teryt, marketType: params.marketType, areaBucket: params.areaBucket }, + apiKey, + ); + return textResponse(formatPriceSpread(data) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool: list_price_spread_locations ─────────────────────────────── + +server.tool( + "list_price_spread_locations", + `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it. +List the cities/counties for which get_price_spread can return data (asking-sale coverage). Use this to discover valid location/teryt values for get_price_spread instead of guessing names. +Each entry is coverage signal only (sale offer sample size + confidence) — it does not compute the spread; call get_price_spread(location|teryt) for the actual spread. +Optional search filters by city name (diacritic-insensitive substring, min 2 chars). Results are sorted by asking_sample_n descending. Free (0 credits).`, + { + search: z.string().min(2, "search must be at least 2 characters").optional().describe( + "Filter by city name (diacritic-insensitive substring, min 2 chars). E.g. 'gda' → Gdańsk. Omit to list the full catalog.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "[Beta] List Price Spread Locations" }, + async (params) => + withErrorHandling("list_price_spread_locations", apiKey, async () => { + requireApiKey(apiKey); + const { data, creditInfo } = await getPriceSpreadLocations( + { search: params.search }, + apiKey, + ); + return textResponse(formatPriceSpreadLocations(data) + formatCreditFooter(creditInfo)); + }), +); + +} // end optional tools + +// ── Tool 10: get_building_breakdown ───────────────────────────────── + +server.tool( + "get_building_breakdown", + `Get the building-by-building breakdown for one transaction: footprint area, number of storeys, and estimated total floor area (footprint × storeys) for each building on the property. +search_transactions / search_by_area / search_by_polygon return per-transaction building SUMS inline; this tool splits them into individual buildings. Use it after a search when a result has building data and you need the detail (e.g. a developed-land deed covering several buildings). +The transaction_id is the id shown on a search result that has building data. Cost: 4 tokens. Returns nothing for a transaction with no buildings.`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result that has building data").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result that carries building data.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Building-by-Building Breakdown" }, + async (params) => + withErrorHandling("get_building_breakdown", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the building array. + // Destructure as `res` (not `data`) to keep that distinction explicit. + const { data: res, creditInfo } = await getBuildingBreakdown(params.transaction_id, apiKey); + return textResponse(formatBuildingBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 11: get_transaction_flood ────────────────────────────────── + +server.tool( + "get_transaction_flood", + `Get the parcel-by-parcel flood-hazard breakdown for one transaction: for each linked plot that sits in a mapped flood zone — the worst hazard category (high/medium/low, i.e. ~1-in-10-year to ~1-in-500-year), the hazard type (river/coastal/infrastructure), the share of the plot inside the zone, and the full per-scenario list (each with its return period). +search_transactions (and search_by_area) surface a per-transaction worst-case flood_risk inline; this tool splits that into the individual parcels and scenarios behind it. Use it after a search when a result shows flood_risk. (search_by_polygon does not include flood inline.) +TWO-STATE: a transaction whose land is in no mapped zone returns nothing — absence of a zone is never asserted as "safe". Cost: 4 tokens (refunded when there is no flood data).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Flood-Hazard Breakdown" }, + async (params) => + withErrorHandling("get_transaction_flood", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the parcel array. + const { data: res, creditInfo } = await getTransactionFlood(params.transaction_id, apiKey); + return textResponse(formatFloodBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 12: get_transaction_heritage ─────────────────────────────── + +server.tool( + "get_transaction_heritage", + `Get the parcel-by-parcel heritage-listing breakdown for one transaction: for each linked plot with a detected heritage listing — the status (listed = a protected monument on/at the plot; zone = the plot lies within a protected urban layout or the designated surroundings of a monument), the share of the plot inside the protected area (when measurable), and the individual entries (category, name, function, period, entry date). +search_transactions (and search_by_area) surface a per-transaction heritage_status inline; this tool splits that into the individual parcels and entries behind it. Use it after a search when a result shows a heritage listing. (search_by_polygon does not include heritage inline.) +TWO-STATE: a transaction with no detected listing returns nothing — absence of a detection is never asserted as "not listed". Indicative data — the regional heritage conservator makes the final, binding determination. Cost: 4 tokens (refunded when there is no heritage data).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Heritage-Listing Breakdown" }, + async (params) => + withErrorHandling("get_transaction_heritage", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the parcel array. + const { data: res, creditInfo } = await getTransactionHeritage(params.transaction_id, apiKey); + return textResponse(formatHeritageBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 13: get_transaction_landslide ────────────────────────────── + +server.tool( + "get_transaction_landslide", + `Get the parcel-by-parcel landslide-hazard breakdown for one transaction, based on official landslide-hazard maps (1:10,000 scale): for each linked plot that intersects a mapped hazard area — the worst category ('landslide' = a mapped landslide area, 'threatened' = an area threatened by mass movements), the share of the plot inside the mapped zones, and the per-zone list (each with its source_version_date — the source-record version date, not a survey/observation date). +An intersection at this scale means the parcel overlaps a mapped hazard area, not that the parcel itself is a landslide. +search_transactions (and search_by_area) surface a per-transaction worst-case landslide_risk inline; this tool splits that into the individual parcels and zones behind it. Use it after a search when a result shows a landslide risk. (search_by_polygon does not include landslide inline.) +TWO-STATE: a transaction whose land is in no mapped zone returns nothing — absence of data is never an assertion of safety. Cost: 4 tokens (refunded when there is no landslide data).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Landslide-Hazard Breakdown" }, + async (params) => + withErrorHandling("get_transaction_landslide", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the parcel array. + const { data: res, creditInfo } = await getTransactionLandslide(params.transaction_id, apiKey); + return textResponse(formatLandslideBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 14: get_transaction_surroundings ─────────────────────────── + +server.tool( + "get_transaction_surroundings", + `Get the plot-by-plot surroundings profile for one transaction: for each linked plot, the distance in meters to the nearest cemetery, landfill (waste disposal site), sewage treatment plant, industrial/storage area, large industrial plant, and intensive livestock farm, from reference land-use and environmental-registry data. Useful for due-diligence on nearby nuisances. +Distances are approximate and measured from the plot boundary; 0 means the plot touches or overlaps such an area. Each category is searched within a fixed radius only: cemetery 1 km, landfill 3 km, sewage treatment 2 km, industrial/storage 1 km, large industrial plant 3 km, intensive livestock farm 3 km. +TWO-STATE: a null/absent distance means no such object within the search radius in the reference data — it is NEVER a guarantee that none exists. assessed=false means the plot has not been evaluated yet (no statement either way). Cost: 4 tokens (refunded when there is no informative data — no linked plots, or none evaluated yet).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Surroundings Breakdown" }, + async (params) => + withErrorHandling("get_transaction_surroundings", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the plot array. + const { data: res, creditInfo } = await getTransactionSurroundings(params.transaction_id, apiKey); + return textResponse(formatSurroundings(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 15: get_transaction_transit ──────────────────────────────── + +server.tool( + "get_transaction_transit", + `Get the parcel-by-parcel public transport access breakdown for one transaction: for each linked plot, the nearest public transport stop distances per transaction parcel, by mode (rail/metro/tram/bus), from open GTFS data — plus the nearest stop's name for each mode present. +A mode is present only when a stop of that mode is within its cap (rail/metro 3000 m, tram 1500 m, bus 1000 m). +TWO-STATE: a transaction whose land has no stop within cap in any mode returns nothing — absence of a row is never asserted as "no transit access" (open feeds cover cities and national rail, not every rural area). Cost: 4 tokens (refunded when there is no transit data).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Public Transport Access Breakdown" }, + async (params) => + withErrorHandling("get_transaction_transit", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated }); `res.data` is the parcel array. + const { data: res, creditInfo } = await getTransactionTransit(params.transaction_id, apiKey); + return textResponse(formatTransitBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 16: get_transaction_permits ──────────────────────────────── + +server.tool( + "get_transaction_permits", + `Get the building-permit history for one transaction's parcels, from the official national registry of positively resolved building permits and works notifications (records since 2016): for each case — its kind (permit / notification), the building intent and works type, the statutory object category, the deciding authority, the decision or intake date, the investment address, and the volume. +Use it after a search to screen what has been built or approved on the transaction's land — a leading indicator of development activity. Match is by the parcel's current identifier, so splits/merges break the link, and only positively resolved cases are held (no pending or refused applications). +TWO-STATE: a transaction whose parcels have no registered case returns nothing — an empty result is never a confirmation that nothing was ever planned. Cost: 4 tokens (refunded when there is no record).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Building-Permit History" }, + async (params) => + withErrorHandling("get_transaction_permits", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated, note }); `res.data` is the record array. + const { data: res, creditInfo } = await getTransactionPermits(params.transaction_id, apiKey); + return textResponse(formatPermitsBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 17: get_transaction_planning ─────────────────────────────── + +server.tool( + "get_transaction_planning", + `Get the general-plan (plan ogólny, POG) zoning for one transaction's land: for each linked plot, the planning zones that cover it — zone symbol and name, the share of the plot each zone covers, and the building parameters the plan sets (max building height, max development intensity, max built-up coverage, min biologically active area) — plus any overlay areas (infill development area / obszar uzupełnienia zabudowy, central development area) that sit on top. +Coverage is honest and THREE-STATE: 'covered' returns zone data; 'covered_no_data' means the municipality has an adopted general plan but no zone data covers these plots in the data yet; 'not_covered' means no published general-plan data for this municipality yet — this is NEVER a claim that the municipality has no plan. General plans are still being adopted across Poland, so coverage grows over time. +Use it for feasibility and permitted-use questions on a plot. Cost: 4 tokens (refunded when there is no zone data for the transaction — 'covered_no_data' or 'not_covered').`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction General-Plan Zoning" }, + async (params) => + withErrorHandling("get_transaction_planning", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated, coverage, ... }); `res.data` is the zone/overlay array. + const { data: res, creditInfo } = await getTransactionPlanning(params.transaction_id, apiKey); + return textResponse(formatPlanningBreakdown(res) + formatCreditFooter(creditInfo)); + }), +); + +// ── Tool 18: get_transaction_farmland ─────────────────────────────── + +server.tool( + "get_transaction_farmland", + `Get the parcel-by-parcel agricultural land-eligibility breakdown for one transaction, from official nationwide agricultural land-eligibility data (updated weekly): for each linked parcel with a matched eligible agricultural area — the eligible area in square metres, its share of the parcel (when the parcel's measured area is known), and how many source features compose it. The response also reports how many of the transaction's linked parcels carry a match and the source snapshot date. Useful for due-diligence on land that is actually eligible/maintained as agricultural (beyond what a registry classification says on paper). +TWO-STATE: a parcel with no matched eligible area returns nothing — absence of a match is NEVER a statement that the property is non-agricultural (small plots that are not actively farmed are simply absent, the reference layer has its own update cadence, and older transactions can reference renumbered parcels). Cost: 4 tokens (refunded when there is no eligible agricultural area for the linked parcels).`, + { + transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe( + "Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result.", + ), + }, + { readOnlyHint: true, destructiveHint: false, title: "Transaction Agricultural Land-Eligibility Breakdown" }, + async (params) => + withErrorHandling("get_transaction_farmland", apiKey, async () => { + requireApiKey(apiKey); + // `res` is the whole response body ({ data, truncated, parcels_total, parcels_with_data, as_of }). + const { data: res, creditInfo } = await getTransactionFarmland(params.transaction_id, apiKey); + return textResponse(formatFarmland(res) + formatCreditFooter(creditInfo)); + }), +); + } // end registerTools diff --git a/src/transport-mode.ts b/src/transport-mode.ts new file mode 100644 index 0000000..e08ce14 --- /dev/null +++ b/src/transport-mode.ts @@ -0,0 +1,18 @@ +/** + * Which transport this process was started on. Single source of truth: the entry point picks + * the transport from it, and the tool layer uses it to tell "you have not configured a key" + * (stdio, an ordinary state) apart from "we lost the auth context" (HTTP, our bug). + */ +export function isHttpMode(): boolean { + return process.argv.includes("--http") || process.env.MCP_TRANSPORT === "http"; +} + +/** + * The `src` tag appended to cenogram.pl links handed to a client: `mcphttp` in HTTP mode, + * `mcpstdio` otherwise. A function rather than a constant so the value follows the active + * transport at call time — both transports serve the same source text, so a tag written as a + * literal is silently wrong for one of them. + */ +export function channelSrc(): string { + return isHttpMode() ? "mcphttp" : "mcpstdio"; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 57442c2..aa2b4e1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,9 @@ { "extends": "./tsconfig.json", + // Comments survive compilation into dist/, and dist/ is what npm ships. A note written for + // whoever maintains this file then travels to every machine that installs the package. + "compilerOptions": { + "removeComments": true + }, "exclude": ["src/**/*.test.ts", "src/__tests__"] } diff --git a/tsconfig.json b/tsconfig.json index c9843a7..fca61f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,9 @@ "skipLibCheck": true, "outDir": "dist", "rootDir": "src", - "declaration": true + "declaration": true, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "include": ["src/**/*.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ff89fe2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // server.test.ts spawns node servers that bind to ports. Keep files sequential and + // worker count bounded so a machine running other suites alongside this one does not + // oversubscribe the CPU and blow boot timeouts. Insurance for future spawning tests too. + fileParallelism: false, + maxWorkers: 2, + // Several suites defer heavy work into beforeAll (dynamic import of the server + // module so mocks hoist first; spawning node servers). Under CPU contention the + // default 10s hook budget is too tight and times the hook out intermittently — + // give it headroom so a slow machine doesn't produce a false failure. + hookTimeout: 30000, + watch: false, + }, +});