scotlis-mcp 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ venv
12
+
13
+ # MCPB build output
14
+ *.mcpb
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .git/
5
+ .gitignore
6
+ .python-version
7
+ uv.lock
8
+ pyproject.toml
9
+ scotlis-mcp.mcpb
10
+ venv/**/__pycache__/
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dart120
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.5
2
+ Name: scotlis-mcp
3
+ Version: 1.0.0
4
+ Summary: MCP server exposing ScotLIS (Registers of Scotland) property price search
5
+ Project-URL: Repository, https://github.com/Dart120/scotlis-mcp
6
+ Author-email: Dart120 <irenitemi81@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.13
10
+ Requires-Dist: mcp[cli]<2,>=1.6.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # scotlis-mcp
14
+
15
+ An MCP server exposing [ScotLIS](https://scotlis.ros.gov.uk) (Registers of Scotland) property price search as a tool Claude can call directly.
16
+
17
+ Given a UK postcode, it returns every registered title at that address with its sale date, price (or non-monetary reason, e.g. a gift between family members), and a link to the property summary.
18
+
19
+ Only covers Scotland, and only titles on the Land Register — older properties still on the Sasine Register may show no price.
20
+
21
+ ## Use as a standalone CLI
22
+
23
+ ```bash
24
+ python3 scotlis_prices.py "EH1 1RE"
25
+ python3 scotlis_prices.py "EH1 1RE" --sort price --desc
26
+ python3 scotlis_prices.py "EH1 1RE" --format csv > prices.csv
27
+ python3 scotlis_prices.py "EH1 1RE" --format json
28
+ ```
29
+
30
+ ## Install as a Claude Desktop extension (one click, macOS only for now)
31
+
32
+ Download `scotlis-mcp.mcpb` from the [latest release](https://github.com/Dart120/scotlis-mcp/releases/latest), then double-click it, or drag it into the Claude Desktop window, or use **Settings > Extensions > Advanced settings > Install Extension…**.
33
+
34
+ This bundle embeds its own Python 3.13 runtime, so it works even if your Mac's system Python is older. It's currently built for macOS only — see [Build the extension bundle](#build-the-extension-bundle) to build one for another platform.
35
+
36
+ ## Use as an MCP server in Claude Desktop (manual config)
37
+
38
+ Requires [uv](https://docs.astral.sh/uv/).
39
+
40
+ ```bash
41
+ git clone https://github.com/Dart120/scotlis-mcp.git
42
+ cd scotlis-mcp
43
+ uv sync
44
+ ```
45
+
46
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "scotlis-prices": {
52
+ "command": "uv",
53
+ "args": ["--directory", "/absolute/path/to/scotlis-mcp", "run", "server.py"]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Restart Claude Desktop, and Claude will be able to call `search_property_prices` for any UK postcode.
60
+
61
+ ## Build the extension bundle
62
+
63
+ ```bash
64
+ uv sync
65
+ npm install -g @anthropic-ai/mcpb
66
+ cp -R .venv venv # bundles a real, self-contained interpreter — not a symlink
67
+ mcpb pack . scotlis-mcp.mcpb
68
+ ```
69
+
70
+ ## Disclaimer
71
+
72
+ Unofficial tool, not affiliated with Registers of Scotland. Queries ScotLIS's public search form directly — be a good citizen and don't hammer it.
@@ -0,0 +1,60 @@
1
+ # scotlis-mcp
2
+
3
+ An MCP server exposing [ScotLIS](https://scotlis.ros.gov.uk) (Registers of Scotland) property price search as a tool Claude can call directly.
4
+
5
+ Given a UK postcode, it returns every registered title at that address with its sale date, price (or non-monetary reason, e.g. a gift between family members), and a link to the property summary.
6
+
7
+ Only covers Scotland, and only titles on the Land Register — older properties still on the Sasine Register may show no price.
8
+
9
+ ## Use as a standalone CLI
10
+
11
+ ```bash
12
+ python3 scotlis_prices.py "EH1 1RE"
13
+ python3 scotlis_prices.py "EH1 1RE" --sort price --desc
14
+ python3 scotlis_prices.py "EH1 1RE" --format csv > prices.csv
15
+ python3 scotlis_prices.py "EH1 1RE" --format json
16
+ ```
17
+
18
+ ## Install as a Claude Desktop extension (one click, macOS only for now)
19
+
20
+ Download `scotlis-mcp.mcpb` from the [latest release](https://github.com/Dart120/scotlis-mcp/releases/latest), then double-click it, or drag it into the Claude Desktop window, or use **Settings > Extensions > Advanced settings > Install Extension…**.
21
+
22
+ This bundle embeds its own Python 3.13 runtime, so it works even if your Mac's system Python is older. It's currently built for macOS only — see [Build the extension bundle](#build-the-extension-bundle) to build one for another platform.
23
+
24
+ ## Use as an MCP server in Claude Desktop (manual config)
25
+
26
+ Requires [uv](https://docs.astral.sh/uv/).
27
+
28
+ ```bash
29
+ git clone https://github.com/Dart120/scotlis-mcp.git
30
+ cd scotlis-mcp
31
+ uv sync
32
+ ```
33
+
34
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "scotlis-prices": {
40
+ "command": "uv",
41
+ "args": ["--directory", "/absolute/path/to/scotlis-mcp", "run", "server.py"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Restart Claude Desktop, and Claude will be able to call `search_property_prices` for any UK postcode.
48
+
49
+ ## Build the extension bundle
50
+
51
+ ```bash
52
+ uv sync
53
+ npm install -g @anthropic-ai/mcpb
54
+ cp -R .venv venv # bundles a real, self-contained interpreter — not a symlink
55
+ mcpb pack . scotlis-mcp.mcpb
56
+ ```
57
+
58
+ ## Disclaimer
59
+
60
+ Unofficial tool, not affiliated with Registers of Scotland. Queries ScotLIS's public search form directly — be a good citizen and don't hammer it.
@@ -0,0 +1,39 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "scotlis-mcp",
4
+ "version": "1.0.0",
5
+ "description": "Search Scottish property sale prices (ScotLIS) by postcode",
6
+ "long_description": "Queries ScotLIS (Registers of Scotland's public land register) for every registered title at a UK postcode and returns the sale date, price, and consideration for each. Covers Scotland only, and only titles on the Land Register.",
7
+ "author": {
8
+ "name": "Dart120",
9
+ "email": "irenitemi81@gmail.com",
10
+ "url": "https://github.com/Dart120"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/Dart120/scotlis-mcp"
15
+ },
16
+ "license": "MIT",
17
+ "server": {
18
+ "type": "python",
19
+ "entry_point": "server.py",
20
+ "mcp_config": {
21
+ "command": "${__dirname}/venv/bin/python3",
22
+ "args": [
23
+ "${__dirname}/server.py"
24
+ ]
25
+ }
26
+ },
27
+ "tools": [
28
+ {
29
+ "name": "search_property_prices",
30
+ "description": "Search registered property sale prices in Scotland by postcode"
31
+ }
32
+ ],
33
+ "compatibility": {
34
+ "platforms": ["darwin"],
35
+ "runtimes": {
36
+ "python": ">=3.13"
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "scotlis-mcp"
3
+ version = "1.0.0"
4
+ description = "MCP server exposing ScotLIS (Registers of Scotland) property price search"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Dart120", email = "irenitemi81@gmail.com" },
10
+ ]
11
+ dependencies = [
12
+ "mcp[cli]>=1.6.0,<2",
13
+ ]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/Dart120/scotlis-mcp"
17
+
18
+ [project.scripts]
19
+ scotlis-mcp = "server:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ only-include = ["server.py", "scotlis_prices.py"]
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """Search Scottish property prices from ScotLIS (Registers of Scotland).
3
+
4
+ ScotLIS renders its results server-side with Next.js, so the search results are
5
+ embedded in the page as a JSON blob (``__NEXT_DATA__``). This script submits the
6
+ same query the public search form does and reads that blob, which avoids both
7
+ HTML scraping and a browser.
8
+
9
+ Usage:
10
+ python3 scotlis_prices.py "EH1 1RE"
11
+ python3 scotlis_prices.py "EH1 1RE" --sort price --desc
12
+ python3 scotlis_prices.py "EH1 1RE" --format csv > prices.csv
13
+ python3 scotlis_prices.py "EH1 1RE" --format json
14
+
15
+ Price searches are postcode-only; title numbers are not accepted by ScotLIS for
16
+ this search type.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import csv
23
+ import datetime as dt
24
+ import json
25
+ import re
26
+ import sys
27
+ import time
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+ from dataclasses import dataclass, asdict
32
+ from typing import Iterable
33
+
34
+ BASE_URL = "https://scotlis.ros.gov.uk"
35
+ RESULTS_URL = f"{BASE_URL}/results"
36
+ PROPERTY_URL = f"{BASE_URL}/property-summary/{{title}}"
37
+
38
+ USER_AGENT = (
39
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
40
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
41
+ )
42
+
43
+ # ScotLIS only populates the price/date fields when sortBy is one of these.
44
+ # Anything else (including "price") silently returns results with those fields
45
+ # stripped out, so we always request a safe value and sort locally.
46
+ _SAFE_SORT_BY = "address"
47
+
48
+ _NEXT_DATA_RE = re.compile(
49
+ r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', re.S
50
+ )
51
+ _POSTCODE_RE = re.compile(
52
+ r"^(?P<out>[A-Z]{1,2}\d[A-Z\d]?)\s*(?P<in>\d[A-Z]{2})$", re.I
53
+ )
54
+ _PRICE_RE = re.compile(r"£\s*([\d,]+)")
55
+
56
+
57
+ class ScotLISError(RuntimeError):
58
+ """Raised when ScotLIS cannot be queried or its response is unusable."""
59
+
60
+
61
+ @dataclass
62
+ class Sale:
63
+ """One registered title and its most recent recorded consideration."""
64
+
65
+ title_number: str
66
+ address: str
67
+ date: dt.date | None
68
+ price: int | None
69
+ consideration: str
70
+ explanation: str | None
71
+ url: str
72
+
73
+ @property
74
+ def price_display(self) -> str:
75
+ return f"£{self.price:,}" if self.price is not None else "—"
76
+
77
+ def as_dict(self) -> dict:
78
+ d = asdict(self)
79
+ d["date"] = self.date.isoformat() if self.date else None
80
+ return d
81
+
82
+
83
+ def normalise_postcode(raw: str) -> str:
84
+ """Return a postcode as ``EH1 1RE``, or raise ValueError."""
85
+ compact = re.sub(r"\s+", "", raw.upper())
86
+ m = _POSTCODE_RE.match(compact)
87
+ if not m:
88
+ raise ValueError(f"{raw!r} does not look like a UK postcode")
89
+ return f"{m.group('out')} {m.group('in')}".upper()
90
+
91
+
92
+ def _fetch(url: str, timeout: float, retries: int) -> str:
93
+ """GET a URL, retrying transient failures with a short backoff."""
94
+ last_error: Exception | None = None
95
+ for attempt in range(retries + 1):
96
+ req = urllib.request.Request(
97
+ url,
98
+ headers={
99
+ "User-Agent": USER_AGENT,
100
+ "Accept": "text/html,application/xhtml+xml",
101
+ "Accept-Language": "en-GB,en;q=0.9",
102
+ },
103
+ )
104
+ try:
105
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
106
+ return resp.read().decode("utf-8", errors="replace")
107
+ except urllib.error.HTTPError as exc:
108
+ body = exc.read().decode("utf-8", errors="replace")[:200]
109
+ if exc.code in (403, 429) and "captcha" in body.lower():
110
+ raise ScotLISError(
111
+ "ScotLIS served a CAPTCHA challenge (AWS WAF). Slow down or "
112
+ "run the search in a browser."
113
+ ) from exc
114
+ if exc.code < 500 and exc.code != 429:
115
+ raise ScotLISError(f"ScotLIS returned HTTP {exc.code}: {body}") from exc
116
+ last_error = exc
117
+ except (urllib.error.URLError, TimeoutError) as exc:
118
+ last_error = exc
119
+ if attempt < retries:
120
+ time.sleep(2 ** attempt)
121
+ raise ScotLISError(f"Could not reach ScotLIS: {last_error}")
122
+
123
+
124
+ def _extract_page_props(html: str) -> dict:
125
+ m = _NEXT_DATA_RE.search(html)
126
+ if not m:
127
+ raise ScotLISError(
128
+ "No __NEXT_DATA__ block in the response — the ScotLIS page layout "
129
+ "has probably changed."
130
+ )
131
+ try:
132
+ return json.loads(m.group(1))["props"]["pageProps"]
133
+ except (ValueError, KeyError) as exc:
134
+ raise ScotLISError(f"Could not parse the ScotLIS page data: {exc}") from exc
135
+
136
+
137
+ def _parse_price(consideration: str | None) -> int | None:
138
+ """Pull an integer out of '£340,000'; non-monetary entries return None."""
139
+ if not consideration:
140
+ return None
141
+ m = _PRICE_RE.search(consideration)
142
+ return int(m.group(1).replace(",", "")) if m else None
143
+
144
+
145
+ def _parse_date(value: str | None) -> dt.date | None:
146
+ if not value:
147
+ return None
148
+ try:
149
+ return dt.date.fromisoformat(value[:10])
150
+ except ValueError:
151
+ return None
152
+
153
+
154
+ def _tidy_address(pretty: str, postcode: str) -> str:
155
+ """ScotLIS prints the postcode unspaced ('EH11RE') at the end; fix that."""
156
+ compact = postcode.replace(" ", "")
157
+ if pretty.upper().endswith(compact):
158
+ return pretty[: -len(compact)] + postcode
159
+ return pretty
160
+
161
+
162
+ def search_prices(
163
+ postcode: str, *, timeout: float = 30.0, retries: int = 2
164
+ ) -> list[Sale]:
165
+ """Return the registered sales ScotLIS holds for a postcode."""
166
+ postcode = normalise_postcode(postcode)
167
+ query = urllib.parse.urlencode(
168
+ {
169
+ "searchType": "prices",
170
+ "postcode": postcode,
171
+ "sortBy": _SAFE_SORT_BY,
172
+ "sortDir": "asc",
173
+ }
174
+ )
175
+ props = _extract_page_props(_fetch(f"{RESULTS_URL}?{query}", timeout, retries))
176
+
177
+ sales = []
178
+ for entry in props.get("addresses") or []:
179
+ title = entry.get("titleNumber", "")
180
+ consideration = entry.get("consideration") or "No price available"
181
+ sales.append(
182
+ Sale(
183
+ title_number=title,
184
+ address=_tidy_address(entry.get("prettyPrint", ""), postcode),
185
+ date=_parse_date(entry.get("entryDate")),
186
+ price=_parse_price(consideration),
187
+ consideration=consideration,
188
+ explanation=entry.get("considerationExplanation"),
189
+ url=PROPERTY_URL.format(title=title),
190
+ )
191
+ )
192
+ return sales
193
+
194
+
195
+ def sort_sales(sales: list[Sale], key: str, descending: bool) -> list[Sale]:
196
+ """Sort locally — ScotLIS returns results in a fixed order regardless of sortBy."""
197
+ # Rows with no price or date sort last in either direction.
198
+ if key == "price":
199
+ ranked = sorted(
200
+ (s for s in sales if s.price is not None),
201
+ key=lambda s: s.price,
202
+ reverse=descending,
203
+ )
204
+ return ranked + [s for s in sales if s.price is None]
205
+ if key == "date":
206
+ ranked = sorted(
207
+ (s for s in sales if s.date is not None),
208
+ key=lambda s: s.date,
209
+ reverse=descending,
210
+ )
211
+ return ranked + [s for s in sales if s.date is None]
212
+ return sorted(sales, key=lambda s: s.address, reverse=descending)
213
+
214
+
215
+ def print_table(sales: Iterable[Sale], postcode: str) -> None:
216
+ sales = list(sales)
217
+ priced = [s.price for s in sales if s.price is not None]
218
+ addr_width = min(max((len(s.address) for s in sales), default=10), 60)
219
+
220
+ print(f"\n{len(sales)} result(s) for {postcode}\n")
221
+ header = f"{'DATE':<12} {'PRICE':>10} {'TITLE':<10} ADDRESS"
222
+ print(header)
223
+ print("-" * (len(header) + addr_width - 7))
224
+ for s in sales:
225
+ date = s.date.strftime("%d %b %Y") if s.date else "—"
226
+ # Non-monetary considerations are free text, so they trail the address
227
+ # rather than blowing out the price column.
228
+ note = f" · {s.consideration}" if s.price is None else ""
229
+ print(f"{date:<12} {s.price_display:>10} {s.title_number:<10} {s.address}{note}")
230
+
231
+ if priced:
232
+ priced.sort()
233
+ mid = len(priced) // 2
234
+ median = priced[mid] if len(priced) % 2 else (priced[mid - 1] + priced[mid]) // 2
235
+ print(
236
+ f"\n{len(priced)} with a recorded price — "
237
+ f"median £{median:,}, range £{min(priced):,}–£{max(priced):,}"
238
+ )
239
+ unpriced = len(sales) - len(priced)
240
+ if unpriced:
241
+ print(f"{unpriced} without a monetary price (gifts, transfers, pre-2003 titles).")
242
+
243
+
244
+ def main(argv: list[str] | None = None) -> int:
245
+ parser = argparse.ArgumentParser(
246
+ description="Search property prices on ScotLIS by postcode."
247
+ )
248
+ parser.add_argument("postcode", help="e.g. 'EH1 1RE'")
249
+ parser.add_argument(
250
+ "--sort", choices=("address", "date", "price"), default="address"
251
+ )
252
+ parser.add_argument("--desc", action="store_true", help="sort descending")
253
+ parser.add_argument(
254
+ "--format", choices=("table", "json", "csv"), default="table"
255
+ )
256
+ parser.add_argument("--timeout", type=float, default=30.0)
257
+ args = parser.parse_args(argv)
258
+
259
+ try:
260
+ postcode = normalise_postcode(args.postcode)
261
+ sales = sort_sales(search_prices(postcode, timeout=args.timeout), args.sort, args.desc)
262
+ except ValueError as exc:
263
+ print(f"error: {exc}", file=sys.stderr)
264
+ return 2
265
+ except ScotLISError as exc:
266
+ print(f"error: {exc}", file=sys.stderr)
267
+ return 1
268
+
269
+ if not sales:
270
+ print(
271
+ f"No registered titles found for {postcode}. ScotLIS only covers "
272
+ "Scotland, and only properties on the Land Register.",
273
+ file=sys.stderr,
274
+ )
275
+ return 0
276
+
277
+ if args.format == "json":
278
+ json.dump([s.as_dict() for s in sales], sys.stdout, indent=2)
279
+ print()
280
+ elif args.format == "csv":
281
+ writer = csv.DictWriter(
282
+ sys.stdout,
283
+ fieldnames=["title_number", "address", "date", "price", "consideration", "explanation", "url"],
284
+ )
285
+ writer.writeheader()
286
+ writer.writerows(s.as_dict() for s in sales)
287
+ else:
288
+ print_table(sales, postcode)
289
+ return 0
290
+
291
+
292
+ if __name__ == "__main__":
293
+ sys.exit(main())
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """MCP server exposing ScotLIS property-price search as tools for Claude Desktop.
3
+
4
+ Wraps scotlis_prices.py (the standalone CLI) so Claude can call it directly
5
+ instead of a human running it in a terminal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from scotlis_prices import (
13
+ ScotLISError,
14
+ search_prices,
15
+ sort_sales,
16
+ normalise_postcode,
17
+ )
18
+
19
+ mcp = FastMCP("scotlis-prices")
20
+
21
+
22
+ @mcp.tool()
23
+ def search_property_prices(
24
+ postcode: str,
25
+ sort_by: str = "address",
26
+ descending: bool = False,
27
+ ) -> dict:
28
+ """Search registered property sale prices in Scotland by postcode.
29
+
30
+ Queries ScotLIS (Registers of Scotland's public land register) for every
31
+ registered title at a postcode and returns, for each, the title number,
32
+ address, sale date, and consideration (sale price, or a non-monetary
33
+ reason such as a gift between family members).
34
+
35
+ Only covers Scotland, and only titles on the Land Register (older
36
+ properties still on the Sasine Register, or recently transferred for
37
+ non-monetary consideration, may show no price).
38
+
39
+ Args:
40
+ postcode: A UK postcode, e.g. "EH1 1RE" (spacing/case don't matter).
41
+ sort_by: One of "address", "date", "price". Defaults to "address".
42
+ descending: Sort highest/latest/Z-first when true.
43
+
44
+ Returns:
45
+ A dict with the normalised postcode, result count, a list of sales
46
+ (title_number, address, date, price, consideration, explanation,
47
+ url), and summary stats (count with a price, median/min/max price)
48
+ when at least one sale has a recorded price.
49
+ """
50
+ try:
51
+ postcode_norm = normalise_postcode(postcode)
52
+ sales = sort_sales(search_prices(postcode_norm), sort_by, descending)
53
+ except ValueError as exc:
54
+ return {"error": str(exc)}
55
+ except ScotLISError as exc:
56
+ return {"error": str(exc)}
57
+
58
+ priced = sorted(s.price for s in sales if s.price is not None)
59
+ summary = {}
60
+ if priced:
61
+ mid = len(priced) // 2
62
+ median = priced[mid] if len(priced) % 2 else (priced[mid - 1] + priced[mid]) // 2
63
+ summary = {
64
+ "priced_count": len(priced),
65
+ "median_price": median,
66
+ "min_price": priced[0],
67
+ "max_price": priced[-1],
68
+ }
69
+
70
+ return {
71
+ "postcode": postcode_norm,
72
+ "result_count": len(sales),
73
+ "summary": summary,
74
+ "sales": [s.as_dict() for s in sales],
75
+ }
76
+
77
+
78
+ def main() -> None:
79
+ mcp.run()
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()