kataster-mcp 0.3.0__py3-none-any.whl

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,3 @@
1
+ """MCP server for the Slovak land registry."""
2
+
3
+ __version__ = "0.3.0"
kataster_mcp/cache.py ADDED
@@ -0,0 +1,232 @@
1
+ """Local SQLite cache and the daily owners quota.
2
+
3
+ Two files under the data dir (0700):
4
+ - cache.sqlite public data (geometry, layers)
5
+ - osoby.sqlite personal data (owners, land users), always 0600
6
+ and the state of the limits in a fixed per-user directory (default_limits_dir):
7
+ - stav.sqlite the daily owners quota and a portal block, 0600
8
+ - tempo/ one file per host with the time of its last request (pacing.Pacer)
9
+
10
+ Every entry is keyed by (source, parser version, key). The parser version is a hash
11
+ of the parser's source code, so fixing a parser silently invalidates old entries.
12
+ """
13
+
14
+ import hashlib
15
+ import inspect
16
+ import json
17
+ import os
18
+ import sqlite3
19
+ import sys
20
+ import time
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from zoneinfo import ZoneInfo
24
+
25
+ # Owner-only file permissions exist only on POSIX; on Windows chmod just toggles the read-only flag and the
26
+ # data directory under %LOCALAPPDATA% is protected by the user profile's ACL instead.
27
+ POSIX = os.name == "posix"
28
+
29
+
30
+ TZ = ZoneInfo("Europe/Bratislava") # on Windows from the tzdata package (a dependency there)
31
+
32
+ DAY = 86400
33
+
34
+
35
+ def default_data_dir(platform: str | None = None, env: dict | None = None) -> Path:
36
+ """Per-user data directory: Windows %LOCALAPPDATA%, macOS ~/Library/Application Support,
37
+ elsewhere $XDG_DATA_HOME or ~/.local/share."""
38
+ platform = platform or sys.platform
39
+ env = os.environ if env is None else env
40
+ if platform == "win32":
41
+ base = env.get("LOCALAPPDATA") or os.path.join(Path.home(), "AppData", "Local")
42
+ elif platform == "darwin":
43
+ base = os.path.join(Path.home(), "Library", "Application Support")
44
+ else:
45
+ base = env.get("XDG_DATA_HOME") or os.path.join(Path.home(), ".local", "share")
46
+ return Path(base) / "kataster-mcp"
47
+
48
+
49
+ def configured_data_dir() -> Path:
50
+ """Data directory (cache, imports, snapshots): KATASTER_DATA_DIR when set, else default_data_dir().
51
+ It never moves the limits (default_limits_dir)."""
52
+ configured = os.environ.get("KATASTER_DATA_DIR")
53
+ return Path(configured).expanduser() if configured else default_data_dir()
54
+
55
+
56
+ def default_limits_dir(platform: str | None = None) -> Path:
57
+ """Fixed per-user directory for the state of the limits (daily owners quota, pause across processes, portal
58
+ block). Unlike the data directory it follows no environment variable or configuration:
59
+ another data directory or XDG_DATA_HOME must not mean a new quota. Linux and others ~/.local/state/kataster-mcp,
60
+ macOS ~/Library/Application Support/kataster-mcp-limits, Windows <profile>\\AppData\\Local\\kataster-mcp-limits."""
61
+ platform = platform or sys.platform
62
+ home = _home()
63
+ if platform == "win32":
64
+ return home / "AppData" / "Local" / "kataster-mcp-limits"
65
+ if platform == "darwin":
66
+ return home / "Library" / "Application Support" / "kataster-mcp-limits"
67
+ return home / ".local" / "state" / "kataster-mcp"
68
+
69
+
70
+ def _home() -> Path:
71
+ """Home directory from the account database on POSIX (not $HOME), the user profile on Windows."""
72
+ try:
73
+ import pwd
74
+ return Path(pwd.getpwuid(os.getuid()).pw_dir)
75
+ except (ImportError, KeyError, AttributeError):
76
+ return Path.home()
77
+
78
+
79
+ # os.open() on Windows opens the C runtime descriptor in text mode unless O_BINARY is given (0 on POSIX).
80
+ O_BINARY = getattr(os, "O_BINARY", 0)
81
+
82
+
83
+ def restrict(path, mode: int) -> None:
84
+ """chmod where it means something (POSIX); a no-op elsewhere, never an error."""
85
+ if POSIX:
86
+ os.chmod(path, mode)
87
+
88
+
89
+ def protection() -> str:
90
+ return ("owner only (0700 directory, 0600 personal-data files)" if POSIX
91
+ else "user profile ACL of the data directory (no POSIX permissions on this OS)")
92
+
93
+
94
+ def parser_version(*funcs) -> str:
95
+ h = hashlib.sha256()
96
+ for f in funcs:
97
+ h.update(inspect.getsource(f).encode())
98
+ return h.hexdigest()[:12]
99
+
100
+
101
+ def today() -> str:
102
+ return datetime.now(TZ).date().isoformat()
103
+
104
+
105
+ def next_day_iso() -> str:
106
+ """Midnight starting the next calendar day in Bratislava (when the daily quota resets)."""
107
+ from datetime import timedelta
108
+
109
+ d = datetime.now(TZ).date() + timedelta(days=1)
110
+ return datetime(d.year, d.month, d.day, tzinfo=TZ).isoformat(timespec="seconds")
111
+
112
+
113
+ def _ensure_private_file(path: Path) -> None:
114
+ if not path.exists():
115
+ os.close(os.open(path, os.O_CREAT | os.O_WRONLY | O_BINARY, 0o600))
116
+ restrict(path, 0o600)
117
+
118
+
119
+ PERSONAL_TTL = 7 * DAY # personal data (owners, imported LVs, page snapshots) is deleted after this
120
+
121
+
122
+ def purge_files(directory: Path, max_age_s: float) -> int:
123
+ """Delete files older than max_age_s (by modification time); returns how many."""
124
+ if not directory.is_dir():
125
+ return 0
126
+ limit = time.time() - max_age_s
127
+ n = 0
128
+ for f in directory.iterdir():
129
+ try:
130
+ if f.is_file() and f.stat().st_mtime < limit:
131
+ f.unlink()
132
+ n += 1
133
+ except OSError:
134
+ pass
135
+ return n
136
+
137
+
138
+ class Cache:
139
+ """SQLite cache. With max_age_s (personal data) rows older than that are physically deleted when the
140
+ cache opens (then VACUUM) and after every write; freed pages are overwritten (secure_delete)."""
141
+
142
+ def __init__(self, path: Path, *, private: bool, max_age_s: float | None = None):
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ restrict(path.parent, 0o700)
145
+ if private:
146
+ _ensure_private_file(path)
147
+ self.path = path
148
+ self.max_age_s = max_age_s
149
+ self.db = sqlite3.connect(path, check_same_thread=False)
150
+ if max_age_s is not None:
151
+ self.db.execute("PRAGMA secure_delete=ON")
152
+ self.db.execute(
153
+ "CREATE TABLE IF NOT EXISTS cache (zdroj TEXT, ver TEXT, kluc TEXT, ulozene REAL, data TEXT,"
154
+ " PRIMARY KEY (zdroj, ver, kluc))"
155
+ )
156
+ self.db.commit()
157
+ if max_age_s is not None and self.purge():
158
+ self.db.execute("VACUUM")
159
+
160
+ def purge(self) -> int:
161
+ """Delete rows older than max_age_s, whatever their source or version; returns how many."""
162
+ if self.max_age_s is None:
163
+ return 0
164
+ n = self.db.execute("DELETE FROM cache WHERE ulozene < ?", (time.time() - self.max_age_s,)).rowcount
165
+ self.db.commit()
166
+ return n
167
+
168
+ def get(self, zdroj: str, ver: str, kluc: str, ttl_s: float | None):
169
+ row = self.db.execute(
170
+ "SELECT data, ulozene FROM cache WHERE zdroj=? AND ver=? AND kluc=?", (zdroj, ver, kluc)
171
+ ).fetchone()
172
+ if row is None:
173
+ return None
174
+ data, ulozene = row
175
+ if ttl_s is not None and time.time() - ulozene > ttl_s:
176
+ return None
177
+ return json.loads(data), ulozene
178
+
179
+ def put(self, zdroj: str, ver: str, kluc: str, data) -> float:
180
+ ts = time.time()
181
+ self.db.execute(
182
+ "INSERT OR REPLACE INTO cache VALUES (?, ?, ?, ?, ?)",
183
+ (zdroj, ver, kluc, ts, json.dumps(data, ensure_ascii=False)),
184
+ )
185
+ self.db.commit()
186
+ self.purge()
187
+ return ts
188
+
189
+ def stats(self) -> dict:
190
+ rows = self.db.execute("SELECT zdroj, ver, COUNT(*) FROM cache GROUP BY zdroj, ver").fetchall()
191
+ return {f"{z}@{v}": n for z, v, n in rows}
192
+
193
+
194
+ class Quota:
195
+ """Parcels per calendar day whose owners were fetched from the portal."""
196
+
197
+ def __init__(self, db: sqlite3.Connection):
198
+ self.db = db
199
+ db.execute("CREATE TABLE IF NOT EXISTS kvota (den TEXT, parcela TEXT, PRIMARY KEY (den, parcela))")
200
+ db.commit()
201
+
202
+ def merge_today(self, old: Path) -> int:
203
+ """Add today's rows of a quota kept elsewhere before (the data directory); the union is never laxer."""
204
+ if not old.exists():
205
+ return 0
206
+ src = sqlite3.connect(f"file:{old}?mode=ro", uri=True)
207
+ try:
208
+ rows = src.execute("SELECT den, parcela FROM kvota WHERE den=?", (today(),)).fetchall()
209
+ except sqlite3.OperationalError: # no quota table there
210
+ rows = []
211
+ finally:
212
+ src.close()
213
+ before = self.used()
214
+ self.db.executemany("INSERT OR IGNORE INTO kvota VALUES (?, ?)", rows)
215
+ self.db.commit()
216
+ return self.used() - before
217
+
218
+ def used(self, den: str | None = None) -> int:
219
+ return self.db.execute("SELECT COUNT(*) FROM kvota WHERE den=?", (den or today(),)).fetchone()[0]
220
+
221
+ def counted(self, parcela: str, den: str | None = None) -> bool:
222
+ return self.db.execute(
223
+ "SELECT 1 FROM kvota WHERE den=? AND parcela=?", (den or today(), parcela)
224
+ ).fetchone() is not None
225
+
226
+ def record(self, parcela: str, den: str | None = None) -> None:
227
+ self.db.execute("INSERT OR IGNORE INTO kvota VALUES (?, ?)", (den or today(), parcela))
228
+ self.db.commit()
229
+
230
+
231
+ def ts_iso(ts: float) -> str:
232
+ return datetime.fromtimestamp(ts, TZ).isoformat(timespec="seconds")
kataster_mcp/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """sk-kataster: local command interface with compact JSON.
2
+
3
+ doctor, ku, lv url, lv inspect and lv import --dry-run need no server and make no request. lv import, person
4
+ overview, tools and call run the MCP tools in this process through create_server, so call shares the pause between
5
+ requests, the daily owners cap and the portal block with every other process (fixed per-user limits directory).
6
+ --data-dir / KATASTER_DATA_DIR move only the data directory (cache, imports), never the limits.
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import sqlite3
13
+ import sys
14
+ from importlib.metadata import version
15
+ from pathlib import Path
16
+ from tempfile import TemporaryDirectory
17
+ from typing import Any
18
+
19
+ from mcp.server.mcpserver.exceptions import ToolError
20
+
21
+ from . import __version__, ku, lv
22
+ from .cache import configured_data_dir, default_limits_dir, today
23
+ from .envelope import Envelope, KatasterError
24
+ from .policy import PUBLIC_POLICY
25
+
26
+
27
+ class Parser(argparse.ArgumentParser):
28
+ def error(self, message: str) -> None:
29
+ raise KatasterError("invalid_input", message)
30
+
31
+
32
+ def parser() -> Parser:
33
+ root = Parser(prog="sk-kataster", description="Kataster: local LV files, cadastral units and the MCP tools.")
34
+ root.add_argument("--json", action="store_true", help="Compact JSON; indented otherwise.")
35
+ root.add_argument("--data-dir", type=Path, default=None,
36
+ help="Data directory (cache, imports); default KATASTER_DATA_DIR or the per-user one. "
37
+ "The limits directory never moves.")
38
+ root.add_argument("--version", action="version", version=__version__)
39
+ commands = root.add_subparsers(dest="command", required=True)
40
+ commands.add_parser("doctor", help="Version, directories and today's owners quota; no network, no writes.")
41
+ units = commands.add_parser("ku", help="Cadastral units, no network.").add_subparsers(dest="action", required=True)
42
+ search = units.add_parser("search")
43
+ search.add_argument("query")
44
+ search.add_argument("--limit", type=int, default=10)
45
+ units.add_parser("resolve").add_argument("query")
46
+ documents = commands.add_parser("lv", help="Read, import and build the URL of an LV extract.").add_subparsers(
47
+ dest="action", required=True)
48
+ for action in ("inspect", "import"):
49
+ command = documents.add_parser(action)
50
+ command.add_argument("path", type=Path)
51
+ command.add_argument("--full", action="store_true", help="Also parcels and personal data; summary by default.")
52
+ if action == "import":
53
+ command.add_argument("--dry-run", action="store_true", help="Read without storing.")
54
+ url = documents.add_parser("url")
55
+ url.add_argument("--ku", required=True)
56
+ url.add_argument("--lv", required=True)
57
+ person = commands.add_parser("person", help="Overview from your own imports only.").add_subparsers(
58
+ dest="action", required=True)
59
+ overview = person.add_parser("overview")
60
+ overview.add_argument("--surname")
61
+ overview.add_argument("--first-name")
62
+ overview.add_argument("--name", help="Legal person: name.")
63
+ overview.add_argument("--ico")
64
+ overview.add_argument("--birth-date")
65
+ overview.add_argument("--ku")
66
+ commands.add_parser("tools", help="Tool names, or the schema of one.").add_argument("name", nargs="?")
67
+ raw = commands.add_parser("call", help="Run one MCP tool in this process; it may query the portal (same pause "
68
+ "and daily cap as the server) or write files.")
69
+ raw.add_argument("name")
70
+ raw.add_argument("arguments", help="JSON object; see tools NAME for the schema.")
71
+ return root
72
+
73
+
74
+ def compact(data: dict[str, Any]) -> dict[str, Any]:
75
+ """Summary of a parsed LV without parcels or personal data."""
76
+ return {
77
+ "lv": data["lv"],
78
+ "hlavicka": data["hlavicka"],
79
+ "zdroj_typ": data["zdroj_typ"],
80
+ "parcely": len(data.get("cast_a") or []),
81
+ "stavby": len(data.get("stavby") or []),
82
+ "vlastnici": len(data["cast_b"]["vlastnici"]),
83
+ "tarchy": data.get("tarchy"),
84
+ **({"tarchy_poznamka": data["tarchy_poznamka"]} if "tarchy_poznamka" in data else {}),
85
+ "uplnost": data.get("uplnost"),
86
+ **({"ulozenie": data["ulozenie"]} if "ulozenie" in data else {}),
87
+ }
88
+
89
+
90
+ def inspect_file(path: Path, full: bool) -> dict[str, Any]:
91
+ with path.expanduser().open("rb") as handle:
92
+ raw = handle.read(lv.MAX_BYTES + 1)
93
+ if len(raw) > lv.MAX_BYTES:
94
+ raise KatasterError("invalid_input", f"File too large (over {lv.MAX_BYTES} B).")
95
+ data = lv.parse_page(raw)
96
+ env = Envelope()
97
+ env.source(lv.IMPORT, str(path), oficialny=False, z_cache=False)
98
+ lv._coverage_warnings(env, data["lv"], data)
99
+ return env.ok(data if full else compact(data))
100
+
101
+
102
+ def quota_today(limits_dir: Path) -> dict[str, Any]:
103
+ """Today's owners quota read from the fixed limits state, read-only: nothing is created when it is missing."""
104
+ used = 0
105
+ state = limits_dir / "stav.sqlite"
106
+ if state.exists():
107
+ db = sqlite3.connect(f"{state.resolve().as_uri()}?mode=ro", uri=True)
108
+ try:
109
+ row = db.execute("SELECT COUNT(*) FROM kvota WHERE den=?", (today(),)).fetchone()
110
+ used = row[0] if row else 0
111
+ except sqlite3.OperationalError: # no quota table yet
112
+ used = 0
113
+ finally:
114
+ db.close()
115
+ return {"den": today(), "pouzite": used, "limit": PUBLIC_POLICY.owners_daily_hard}
116
+
117
+
118
+ def doctor(data_dir: Path, limits_dir: Path) -> dict[str, Any]:
119
+ return Envelope().ok({
120
+ "version": __version__,
121
+ "sdk": version("mcp"),
122
+ "network_checked": False,
123
+ "data_dir": str(data_dir),
124
+ "limits_dir": str(limits_dir),
125
+ "kvota_vlastnici": quota_today(limits_dir),
126
+ })
127
+
128
+
129
+ def _structured(result: Any) -> dict[str, Any]:
130
+ """The tool's envelope from call_tool, whatever shape the SDK returns."""
131
+ structured = getattr(result, "structured_content", None) # SDK 2.x CallToolResult
132
+ if structured is None and isinstance(result, tuple): # SDK 1.x (content, structured)
133
+ structured = result[1]
134
+ if structured:
135
+ return structured.get("result", structured)
136
+ content = getattr(result, "content", None) or (result[0] if isinstance(result, tuple) else result)
137
+ return json.loads(content[0].text)
138
+
139
+
140
+ async def run_tool(name: str, arguments: dict[str, Any], data_dir: Path, limits_dir: Path | None) -> dict[str, Any]:
141
+ from .server import create_server
142
+
143
+ server = create_server(PUBLIC_POLICY, data_dir=data_dir, limits_dir=limits_dir)
144
+ app = server.app_state
145
+ try:
146
+ if name == "":
147
+ tools = await server.list_tools()
148
+ return Envelope().ok({"tools": [t.name for t in tools]})
149
+ if name.startswith("schema:"):
150
+ wanted = name.removeprefix("schema:")
151
+ tool = next((t for t in await server.list_tools() if t.name == wanted), None)
152
+ if tool is None:
153
+ raise KatasterError("not_found", f"No tool named '{wanted}'.")
154
+ return Envelope().ok(tool.model_dump(by_alias=True, exclude_none=True))
155
+ return _structured(await server.call_tool(name, arguments))
156
+ finally:
157
+ await app.http.aclose()
158
+ for cache in (app.cache, app.osoby):
159
+ cache.db.close()
160
+
161
+
162
+ def execute(args: argparse.Namespace, limits_dir: Path | None = None) -> dict[str, Any]:
163
+ data_dir = args.data_dir or configured_data_dir()
164
+ if args.command == "doctor":
165
+ return doctor(data_dir, limits_dir or default_limits_dir())
166
+ if args.command == "ku":
167
+ if args.action == "resolve":
168
+ return Envelope().ok(ku.resolve(args.query))
169
+ if not 1 <= args.limit <= 100:
170
+ raise KatasterError("invalid_input", "Limit must be 1 to 100.")
171
+ hits = ku.search(args.query)
172
+ return Envelope().ok({"items": hits[: args.limit], "total": len(hits)})
173
+ if args.command == "lv":
174
+ if args.action == "url":
175
+ if not args.lv.isascii() or not args.lv.isdigit():
176
+ raise KatasterError("invalid_input", "LV must be a number.")
177
+ return Envelope().ok({"url": lv.lv_url(ku.resolve(args.ku)["kod"], args.lv, "html", [])})
178
+ if args.action == "inspect" or args.dry_run:
179
+ result = inspect_file(args.path, args.full)
180
+ if args.action == "import":
181
+ result["data"]["dry_run"] = True
182
+ return result
183
+ result = asyncio.run(run_tool("kataster_lv_import", {"path": str(args.path)}, data_dir, limits_dir))
184
+ if not args.full and "cast_a" in (result.get("data") or {}):
185
+ result["data"] = compact(result["data"])
186
+ return result
187
+ if args.command == "person":
188
+ arguments = {"priezvisko": args.surname, "meno": args.first_name, "nazov": args.name, "ico": args.ico,
189
+ "datum_narodenia": args.birth_date, "ku": args.ku}
190
+ return asyncio.run(run_tool("kataster_osoba_prehlad", {k: v for k, v in arguments.items() if v is not None},
191
+ data_dir, limits_dir))
192
+ if args.command == "tools":
193
+ with TemporaryDirectory(prefix="kataster-tools-") as directory: # listing needs no data
194
+ return asyncio.run(run_tool(f"schema:{args.name}" if args.name else "", {}, Path(directory), limits_dir))
195
+ arguments = json.loads(args.arguments)
196
+ if not isinstance(arguments, dict):
197
+ raise KatasterError("invalid_input", "Arguments must be a JSON object.")
198
+ return asyncio.run(run_tool(args.name, arguments, data_dir, limits_dir))
199
+
200
+
201
+ def main(argv: list[str] | None = None, *, limits_dir: Path | None = None) -> int:
202
+ """limits_dir is for tests only (code, never an option or environment variable)."""
203
+ argv = sys.argv[1:] if argv is None else argv
204
+ if hasattr(sys.stdout, "reconfigure"):
205
+ sys.stdout.reconfigure(encoding="utf-8")
206
+ code = 0
207
+ try:
208
+ result = execute(parser().parse_args(argv), limits_dir)
209
+ except ToolError as error:
210
+ text = str(error)
211
+ try:
212
+ result = json.loads(text[text.index("{"):])
213
+ except ValueError:
214
+ result = {"chyba": {"typ": "invalid_input", "sprava": text}, "zdroje": [], "varovania": []} # e.g. bad arguments
215
+ code = 1
216
+ except (KatasterError, OSError, ValueError) as error:
217
+ kind = error.kind if isinstance(error, KatasterError) else (
218
+ "not_found" if isinstance(error, FileNotFoundError) else "invalid_input")
219
+ message = error.message if isinstance(error, KatasterError) else str(error)
220
+ result = {"chyba": {"typ": kind, "sprava": message}, "zdroje": [], "varovania": []}
221
+ code = 1
222
+ print(json.dumps(result, ensure_ascii=False, indent=None if "--json" in argv else 2))
223
+ return code
224
+
225
+
226
+ if __name__ == "__main__":
227
+ raise SystemExit(main())