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.
- scotlis_mcp-1.0.0/.gitignore +14 -0
- scotlis_mcp-1.0.0/.mcpbignore +10 -0
- scotlis_mcp-1.0.0/.python-version +1 -0
- scotlis_mcp-1.0.0/LICENSE +21 -0
- scotlis_mcp-1.0.0/PKG-INFO +72 -0
- scotlis_mcp-1.0.0/README.md +60 -0
- scotlis_mcp-1.0.0/manifest.json +39 -0
- scotlis_mcp-1.0.0/pyproject.toml +26 -0
- scotlis_mcp-1.0.0/scotlis_prices.py +293 -0
- scotlis_mcp-1.0.0/server.py +83 -0
- scotlis_mcp-1.0.0/uv.lock +728 -0
|
@@ -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()
|