regon-mcp 0.2.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.
regon_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """regon-mcp — an MCP server for the Polish GUS REGON business register (BIR1)."""
2
+
3
+ __version__ = "0.2.0"
regon_mcp/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
regon_mcp/client.py ADDED
@@ -0,0 +1,320 @@
1
+ """A small, dependency-light client for the GUS BIR1 REGON SOAP service.
2
+
3
+ Rather than a full WSDL/SOAP stack, this builds the (very stable) SOAP 1.2
4
+ envelopes by hand over ``httpx``. That avoids WCF/WS-Addressing/MTOM pain in
5
+ generic SOAP libraries and keeps the runtime tiny.
6
+
7
+ Session handling:
8
+ * ``Zaloguj(api_key)`` returns a session id (``sid``).
9
+ * The ``sid`` is sent as an HTTP header on every authorized call.
10
+ * Sessions expire after ~60 minutes of inactivity, so the client refreshes
11
+ proactively on a TTL well under that. If the service still rejects a session
12
+ mid-call (a session fault), it re-logs in once and retries — but *only* for a
13
+ positively-identified session fault, never for arbitrary transport errors.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html
19
+ import re
20
+ import threading
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Optional, Sequence
24
+ from xml.sax.saxutils import escape
25
+
26
+ import httpx
27
+
28
+ from . import parse as _parse
29
+ from .envelopes import NS_ACTION, build_envelope
30
+ from .errors import (
31
+ RegonAuthError,
32
+ RegonError,
33
+ RegonProtocolError,
34
+ RegonSessionError,
35
+ RegonTransportError,
36
+ )
37
+
38
+ __all__ = [
39
+ "RegonClient",
40
+ "RegonConfig",
41
+ "RegonError",
42
+ "RegonAuthError",
43
+ "RegonSessionError",
44
+ "RegonTransportError",
45
+ "RegonProtocolError",
46
+ "TEST_KEY",
47
+ "MAX_BATCH",
48
+ ]
49
+
50
+ TEST_KEY = "abcde12345abcde12345"
51
+
52
+ ENDPOINTS = {
53
+ "test": "https://wyszukiwarkaregontest.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc",
54
+ "prod": "https://wyszukiwarkaregon.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc",
55
+ }
56
+
57
+ # Refresh well before the service's ~60-minute inactivity expiry.
58
+ _SESSION_TTL = 30 * 60
59
+
60
+ # GUS accepts at most 20 identifiers in one batch search.
61
+ MAX_BATCH = 20
62
+
63
+ # Reject absurdly large HTTP bodies before we try to parse them.
64
+ _MAX_RESPONSE_BYTES = 16 * 1024 * 1024
65
+
66
+ _ENVELOPE_RE = re.compile(r"<(?:\w+:)?Envelope\b.*</(?:\w+:)?Envelope>", re.S)
67
+ # Fault text that indicates the session (sid) was rejected/expired.
68
+ _SESSION_FAULT_RE = re.compile(
69
+ r"sesj|session|zaloguj|identyfikator|uwierzytelni|authentic", re.I
70
+ )
71
+
72
+
73
+ def _extract_envelope(raw: str) -> str:
74
+ match = _ENVELOPE_RE.search(raw)
75
+ if not match:
76
+ raise RegonProtocolError("no SOAP envelope found in the response")
77
+ return match.group(0)
78
+
79
+
80
+ def _result_text(raw: str, tag: str) -> str:
81
+ """Return the (unescaped) text of ``<tag>`` from a SOAP response body.
82
+
83
+ Raises ``RegonSessionError`` for a session-related SOAP fault (recoverable by
84
+ re-login) and ``RegonProtocolError`` for any other fault or missing element.
85
+ """
86
+ envelope = _extract_envelope(raw)
87
+ if re.search(rf"<{tag}\s*/>", envelope):
88
+ return ""
89
+ match = re.search(rf"<{tag}(?:\s[^>]*)?>(.*?)</{tag}>", envelope, re.S)
90
+ if match:
91
+ return html.unescape(match.group(1))
92
+ fault = re.search(r"<(?:\w+:)?Text[^>]*>(.*?)</(?:\w+:)?Text>", envelope, re.S)
93
+ if fault:
94
+ message = fault.group(1).strip()
95
+ if _SESSION_FAULT_RE.search(message):
96
+ raise RegonSessionError(f"session rejected by service: {message}")
97
+ raise RegonProtocolError(f"SOAP fault: {message}")
98
+ raise RegonProtocolError(f"response did not contain <{tag}>")
99
+
100
+
101
+ @dataclass
102
+ class RegonConfig:
103
+ api_key: str = TEST_KEY
104
+ environment: str = "test"
105
+ timeout: float = 30.0
106
+
107
+ def __post_init__(self) -> None:
108
+ if self.environment not in ENDPOINTS:
109
+ raise ValueError(
110
+ f"environment must be one of {sorted(ENDPOINTS)}; got {self.environment!r}"
111
+ )
112
+ if not str(self.api_key).strip():
113
+ raise ValueError("api_key must not be empty")
114
+ if not (self.timeout > 0):
115
+ raise ValueError(f"timeout must be positive; got {self.timeout!r}")
116
+
117
+ @property
118
+ def endpoint(self) -> str:
119
+ return ENDPOINTS[self.environment]
120
+
121
+ @property
122
+ def is_test_key(self) -> bool:
123
+ return self.api_key == TEST_KEY
124
+
125
+
126
+ class RegonClient:
127
+ """Thread-safe client managing the BIR1 login session lifecycle."""
128
+
129
+ def __init__(self, config: Optional[RegonConfig] = None):
130
+ self.config = config or RegonConfig()
131
+ self._sid: Optional[str] = None
132
+ self._sid_at: float = 0.0
133
+ self._lock = threading.RLock()
134
+ self._http = httpx.Client(timeout=self.config.timeout)
135
+
136
+ # ---- transport ---------------------------------------------------------
137
+ def _post(self, action: str, body: str, sid: Optional[str]) -> str:
138
+ headers = {
139
+ "Content-Type": (
140
+ f'application/soap+xml;charset=UTF-8;action="{NS_ACTION}/{action}"'
141
+ )
142
+ }
143
+ if sid:
144
+ headers["sid"] = sid
145
+ envelope = build_envelope(self.config.endpoint, action, body)
146
+ try:
147
+ response = self._http.post(
148
+ self.config.endpoint, content=envelope.encode("utf-8"), headers=headers
149
+ )
150
+ response.raise_for_status()
151
+ except httpx.HTTPStatusError as exc:
152
+ raise RegonTransportError(
153
+ f"{action} failed: HTTP {exc.response.status_code} from {self.config.endpoint}"
154
+ ) from exc
155
+ except httpx.HTTPError as exc:
156
+ raise RegonTransportError(f"{action} request failed: {exc}") from exc
157
+ if len(response.content) > _MAX_RESPONSE_BYTES:
158
+ raise RegonProtocolError(f"{action} response exceeds the maximum allowed size")
159
+ return response.text
160
+
161
+ # ---- session -----------------------------------------------------------
162
+ def _wyloguj(self, sid: str) -> bool:
163
+ body = (
164
+ "<ns:Wyloguj><ns:pIdentyfikatorSesji>"
165
+ f"{escape(sid)}"
166
+ "</ns:pIdentyfikatorSesji></ns:Wyloguj>"
167
+ )
168
+ raw = self._post("Wyloguj", body, sid=sid)
169
+ return _result_text(raw, "WylogujResult").strip().lower() == "true"
170
+
171
+ def _logout_quiet(self, sid: str) -> None:
172
+ """Best-effort logout so replaced sessions don't leak server-side."""
173
+ try:
174
+ self._wyloguj(sid)
175
+ except RegonError:
176
+ pass
177
+
178
+ def _login(self) -> str:
179
+ body = (
180
+ "<ns:Zaloguj><ns:pKluczUzytkownika>"
181
+ f"{escape(self.config.api_key)}"
182
+ "</ns:pKluczUzytkownika></ns:Zaloguj>"
183
+ )
184
+ raw = self._post("Zaloguj", body, sid=None)
185
+ sid = _result_text(raw, "ZalogujResult").strip()
186
+ if not sid:
187
+ raise RegonAuthError("login failed: empty session id (check REGON_API_KEY).")
188
+ return sid
189
+
190
+ def _session(self, force: bool = False) -> str:
191
+ with self._lock:
192
+ fresh = self._sid and (time.monotonic() - self._sid_at) < _SESSION_TTL
193
+ if force or not fresh:
194
+ old = self._sid
195
+ self._sid = None
196
+ self._sid_at = 0.0
197
+ if old:
198
+ self._logout_quiet(old)
199
+ self._sid = self._login()
200
+ self._sid_at = time.monotonic()
201
+ return self._sid
202
+
203
+ def _authorized(self, action: str, body: str, result_tag: str) -> str:
204
+ with self._lock:
205
+ raw = self._post(action, body, sid=self._session())
206
+ try:
207
+ return _result_text(raw, result_tag)
208
+ except RegonSessionError:
209
+ # Positively-identified session rejection: re-login once, retry once.
210
+ raw = self._post(action, body, sid=self._session(force=True))
211
+ return _result_text(raw, result_tag)
212
+
213
+ def login(self) -> str:
214
+ """Force a fresh login and return the session id."""
215
+ return self._session(force=True)
216
+
217
+ def logout(self) -> bool:
218
+ with self._lock:
219
+ if not self._sid:
220
+ return True
221
+ sid = self._sid
222
+ try:
223
+ return self._wyloguj(sid)
224
+ finally:
225
+ self._sid = None
226
+ self._sid_at = 0.0
227
+
228
+ # ---- operations --------------------------------------------------------
229
+ def search(
230
+ self,
231
+ *,
232
+ nip: Optional[str] = None,
233
+ regon: Optional[str] = None,
234
+ krs: Optional[str] = None,
235
+ nips: Optional[Sequence[str]] = None,
236
+ krss: Optional[Sequence[str]] = None,
237
+ regons9: Optional[Sequence[str]] = None,
238
+ regons14: Optional[Sequence[str]] = None,
239
+ ) -> list[dict]:
240
+ """Search the register by one identifier or a batch of the same type."""
241
+ for name, seq in (
242
+ ("nips", nips), ("krss", krss), ("regons9", regons9), ("regons14", regons14),
243
+ ):
244
+ if seq is None:
245
+ continue
246
+ if len(seq) == 0:
247
+ raise ValueError(f"{name} must be a non-empty list")
248
+ if len(seq) > MAX_BATCH:
249
+ raise ValueError(f"at most {MAX_BATCH} identifiers per batch search ({name})")
250
+ if any(not str(v).strip() for v in seq):
251
+ raise ValueError(f"{name} contains an empty identifier")
252
+ params = _build_search_params(
253
+ nip=nip, regon=regon, krs=krs,
254
+ nips=nips, krss=krss, regons9=regons9, regons14=regons14,
255
+ )
256
+ body = (
257
+ "<ns:DaneSzukajPodmioty><ns:pParametryWyszukiwania>"
258
+ f"{params}"
259
+ "</ns:pParametryWyszukiwania></ns:DaneSzukajPodmioty>"
260
+ )
261
+ inner = self._authorized(
262
+ "DaneSzukajPodmioty", body, "DaneSzukajPodmiotyResult"
263
+ )
264
+ return _parse.parse_search_results(inner)
265
+
266
+ def full_report(self, regon: str, report_name: str) -> list[dict]:
267
+ """Fetch a detailed report for one entity by REGON."""
268
+ body = (
269
+ "<ns:DanePobierzPelnyRaport>"
270
+ f"<ns:pRegon>{escape(regon)}</ns:pRegon>"
271
+ f"<ns:pNazwaRaportu>{escape(report_name)}</ns:pNazwaRaportu>"
272
+ "</ns:DanePobierzPelnyRaport>"
273
+ )
274
+ inner = self._authorized(
275
+ "DanePobierzPelnyRaport", body, "DanePobierzPelnyRaportResult"
276
+ )
277
+ return _parse.parse_report(inner)
278
+
279
+ # ---- lifecycle ---------------------------------------------------------
280
+ def close(self) -> None:
281
+ try:
282
+ self.logout()
283
+ except RegonError:
284
+ pass
285
+ self._http.close()
286
+
287
+ def __enter__(self) -> "RegonClient":
288
+ return self
289
+
290
+ def __exit__(self, *exc) -> None:
291
+ self.close()
292
+
293
+
294
+ _SINGLE_TAGS = {"nip": "Nip", "regon": "Regon", "krs": "Krs"}
295
+ _BATCH_TAGS = {
296
+ "nips": "Nipy",
297
+ "krss": "Krsy",
298
+ "regons9": "Regony9zn",
299
+ "regons14": "Regony14zn",
300
+ }
301
+
302
+
303
+ def _build_search_params(**kwargs) -> str:
304
+ parts = []
305
+ for key, tag in _SINGLE_TAGS.items():
306
+ value = kwargs.get(key)
307
+ if value:
308
+ parts.append(f"<dat:{tag}>{escape(str(value))}</dat:{tag}>")
309
+ for key, tag in _BATCH_TAGS.items():
310
+ values = kwargs.get(key)
311
+ if values:
312
+ joined = ",".join(escape(str(v)) for v in values)
313
+ parts.append(f"<dat:{tag}>{joined}</dat:{tag}>")
314
+ if not parts:
315
+ raise ValueError(
316
+ "search requires at least one of nip/regon/krs or a batch list"
317
+ )
318
+ if len(parts) > 1:
319
+ raise ValueError("provide only one identifier type per search")
320
+ return parts[0]
regon_mcp/envelopes.py ADDED
@@ -0,0 +1,35 @@
1
+ """SOAP 1.2 + WS-Addressing envelope construction for the GUS BIR1 service.
2
+
3
+ The BIR1 endpoint is a WCF service that requires:
4
+ * SOAP 1.2 with the action carried in the Content-Type header, and
5
+ * WS-Addressing ``To`` / ``Action`` headers in the SOAP header.
6
+
7
+ The session id returned by ``Zaloguj`` is *not* part of the SOAP body — it is
8
+ sent as a plain HTTP header named ``sid`` on subsequent calls (see client.py).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # WS-Addressing action prefix (interface name), shared by every operation.
14
+ NS_ACTION = "http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl"
15
+
16
+ _TEMPLATE = (
17
+ '<soap:Envelope '
18
+ 'xmlns:soap="http://www.w3.org/2003/05/soap-envelope" '
19
+ 'xmlns:ns="http://CIS/BIR/PUBL/2014/07" '
20
+ 'xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract" '
21
+ 'xmlns:wsa="http://www.w3.org/2005/08/addressing">'
22
+ "<soap:Header>"
23
+ "<wsa:To>{endpoint}</wsa:To>"
24
+ "<wsa:Action>{action_ns}/{action}</wsa:Action>"
25
+ "</soap:Header>"
26
+ "<soap:Body>{body}</soap:Body>"
27
+ "</soap:Envelope>"
28
+ )
29
+
30
+
31
+ def build_envelope(endpoint: str, action: str, body: str) -> str:
32
+ """Wrap a SOAP body fragment in a full addressed SOAP 1.2 envelope."""
33
+ return _TEMPLATE.format(
34
+ endpoint=endpoint, action_ns=NS_ACTION, action=action, body=body
35
+ )
regon_mcp/errors.py ADDED
@@ -0,0 +1,27 @@
1
+ """Typed exceptions shared across the package.
2
+
3
+ Kept in a dedicated module so both ``client`` and ``parse`` can raise them
4
+ without a circular import.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class RegonError(Exception):
11
+ """Base error for all REGON/BIR client failures."""
12
+
13
+
14
+ class RegonAuthError(RegonError):
15
+ """Login failed — e.g. an invalid or missing USER_KEY."""
16
+
17
+
18
+ class RegonSessionError(RegonError):
19
+ """The BIR session was rejected or has expired; a re-login may recover."""
20
+
21
+
22
+ class RegonTransportError(RegonError):
23
+ """A network/HTTP-level failure talking to the BIR endpoint."""
24
+
25
+
26
+ class RegonProtocolError(RegonError):
27
+ """The service returned a malformed, oversized, or unexpected response."""
regon_mcp/parse.py ADDED
@@ -0,0 +1,108 @@
1
+ """Parse the XML payloads returned inside BIR1 responses into plain dicts.
2
+
3
+ Search and report results arrive as an XML *string* embedded (HTML-escaped)
4
+ inside the SOAP result element. Once unescaped, that inner document looks like::
5
+
6
+ <root>
7
+ <dane> ... fields ... </dane>
8
+ <dane> ... </dane>
9
+ </root>
10
+
11
+ Each ``<dane>`` is one matched entity.
12
+
13
+ The inner XML comes from an external service, so it is parsed with
14
+ ``defusedxml`` (DTDs and entity declarations rejected) under a size cap to keep
15
+ the long-running MCP process safe from entity-expansion / resource-exhaustion.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Optional
22
+ from xml.etree.ElementTree import Element, ParseError
23
+
24
+ from defusedxml.ElementTree import fromstring as _safe_fromstring
25
+
26
+ from .errors import RegonProtocolError
27
+
28
+ # Generous ceiling for a single REGON payload (real ones are a few KB).
29
+ _MAX_INNER_BYTES = 8 * 1024 * 1024
30
+
31
+ # Friendly snake_case names for the fields returned by DaneSzukajPodmioty.
32
+ _SEARCH_FIELDS = {
33
+ "Regon": "regon",
34
+ "Nip": "nip",
35
+ "StatusNip": "nip_status",
36
+ "Nazwa": "name",
37
+ "Wojewodztwo": "voivodeship",
38
+ "Powiat": "county",
39
+ "Gmina": "commune",
40
+ "Miejscowosc": "city",
41
+ "KodPocztowy": "postal_code",
42
+ "Ulica": "street",
43
+ "NrNieruchomosci": "building_number",
44
+ "NrLokalu": "unit_number",
45
+ "Typ": "type",
46
+ "SilosID": "silo_id",
47
+ "DataZakonczeniaDzialalnosci": "activity_end_date",
48
+ "MiejscowoscPoczty": "post_office_city",
49
+ }
50
+
51
+ # BIR entity type codes.
52
+ TYPE_LABELS = {
53
+ "P": "legal person",
54
+ "F": "natural person",
55
+ "LP": "local unit of a legal person",
56
+ "LF": "local unit of a natural person",
57
+ }
58
+
59
+ _CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
60
+
61
+
62
+ def _snake(name: str) -> str:
63
+ return _CAMEL_BOUNDARY.sub("_", name).lower()
64
+
65
+
66
+ def _text(element: Element) -> Optional[str]:
67
+ value = (element.text or "").strip()
68
+ return value or None
69
+
70
+
71
+ def _rows(inner_xml: str) -> list[Element]:
72
+ """Return the ``<dane>`` elements from a result payload.
73
+
74
+ An empty payload is a legitimate zero-result response; a non-empty but
75
+ unparseable payload is a protocol error, not "no results".
76
+ """
77
+ inner = (inner_xml or "").strip()
78
+ if not inner:
79
+ return []
80
+ if len(inner.encode("utf-8", "ignore")) > _MAX_INNER_BYTES:
81
+ raise RegonProtocolError("REGON payload exceeds the maximum allowed size")
82
+ try:
83
+ root = _safe_fromstring(inner)
84
+ except (ParseError, ValueError) as exc:
85
+ raise RegonProtocolError(f"could not parse REGON XML payload: {exc}") from exc
86
+ return list(root.findall("dane"))
87
+
88
+
89
+ def parse_search_results(inner_xml: str) -> list[dict]:
90
+ """Parse a DaneSzukajPodmioty payload into a list of entity summaries."""
91
+ results = []
92
+ for dane in _rows(inner_xml):
93
+ row = {
94
+ _SEARCH_FIELDS.get(child.tag, _snake(child.tag)): _text(child)
95
+ for child in dane
96
+ }
97
+ if row.get("type"):
98
+ row["type_label"] = TYPE_LABELS.get(row["type"], row["type"])
99
+ results.append(row)
100
+ return results
101
+
102
+
103
+ def parse_report(inner_xml: str) -> list[dict]:
104
+ """Parse a DanePobierzPelnyRaport payload generically (tag -> snake_case)."""
105
+ return [
106
+ {_snake(child.tag): _text(child) for child in dane}
107
+ for dane in _rows(inner_xml)
108
+ ]
regon_mcp/server.py ADDED
@@ -0,0 +1,207 @@
1
+ """MCP server exposing the GUS REGON register as clean, typed tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+ from mcp.server import MCPServer
11
+
12
+ from .client import MAX_BATCH, RegonClient, RegonConfig, TEST_KEY
13
+ from .validate import normalize_krs, normalize_nip, normalize_regon
14
+
15
+ mcp = MCPServer("regon")
16
+
17
+ _config: Optional[RegonConfig] = None
18
+ _client: Optional[RegonClient] = None
19
+
20
+ # Report names accepted by get_full_report(). These are the BIR1.1 reports; the
21
+ # newer BIR1.2 / PKD-2025 report family belongs to a different API version and
22
+ # is intentionally out of scope for this client.
23
+ REPORT_TYPES = {
24
+ "BIR11OsPrawna": "Legal person — full record (type P).",
25
+ "BIR11OsPrawnaPkd": "Legal person — PKD activity codes.",
26
+ "BIR11OsPrawnaListaJednLokalnych": "Legal person — list of local units.",
27
+ "BIR11JednLokalnaOsPrawnej": "Local unit of a legal person — full record.",
28
+ "BIR11JednLokalnaOsPrawnejPkd": "Local unit of a legal person — PKD codes.",
29
+ "BIR11OsFizycznaDaneOgolne": "Natural person — general data (type F).",
30
+ "BIR11OsFizycznaDzialalnoscCeidg": "Natural person — CEIDG business activity (silo 1).",
31
+ "BIR11OsFizycznaDzialalnoscRolnicza": "Natural person — agricultural activity (silo 2).",
32
+ "BIR11OsFizycznaDzialalnoscPozostala": "Natural person — other activity (silo 3).",
33
+ "BIR11OsFizycznaDzialalnoscSkreslonaDo20141108": "Natural person — activity removed before 2014-11-08 (silo 4).",
34
+ "BIR11OsFizycznaPkd": "Natural person — PKD activity codes.",
35
+ "BIR11OsFizycznaListaJednLokalnych": "Natural person — list of local units.",
36
+ "BIR11JednLokalnaOsFizycznej": "Local unit of a natural person — full record.",
37
+ "BIR11JednLokalnaOsFizycznejPkd": "Local unit of a natural person — PKD codes.",
38
+ }
39
+
40
+
41
+ def _load_config() -> RegonConfig:
42
+ """Read and eagerly validate configuration from the environment."""
43
+ global _config
44
+ if _config is None:
45
+ raw_timeout = os.getenv("REGON_TIMEOUT", "30")
46
+ try:
47
+ timeout = float(raw_timeout)
48
+ except ValueError as exc:
49
+ raise ValueError(f"REGON_TIMEOUT must be a number; got {raw_timeout!r}") from exc
50
+ if not (timeout > 0 and math.isfinite(timeout)):
51
+ raise ValueError(f"REGON_TIMEOUT must be a positive finite number; got {timeout!r}")
52
+ # RegonConfig validates api_key non-empty and environment membership.
53
+ _config = RegonConfig(
54
+ api_key=os.getenv("REGON_API_KEY", TEST_KEY),
55
+ environment=os.getenv("REGON_ENV", "test"),
56
+ timeout=timeout,
57
+ )
58
+ return _config
59
+
60
+
61
+ def _get_client() -> RegonClient:
62
+ global _client
63
+ if _client is None:
64
+ _client = RegonClient(_load_config())
65
+ return _client
66
+
67
+
68
+ def _source() -> dict:
69
+ config = _load_config()
70
+ source = {
71
+ "register": "REGON — Krajowy Rejestr Urzędowy Podmiotów Gospodarki Narodowej",
72
+ "provided_by": "Główny Urząd Statystyczny (Statistics Poland / GUS)",
73
+ "api": "BIR1.1",
74
+ "environment": config.environment,
75
+ "using_test_key": config.is_test_key,
76
+ "retrieved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
77
+ "attribution": (
78
+ "When presenting REGON data, cite the source as 'REGON, GUS' "
79
+ "together with the retrieval date."
80
+ ),
81
+ }
82
+ if config.is_test_key:
83
+ source["note"] = (
84
+ "Public test key in use — the GUS test database is anonymized and "
85
+ "returns limited or no data. Set REGON_API_KEY to a production "
86
+ "USER_KEY (and REGON_ENV=prod) for live results."
87
+ )
88
+ return source
89
+
90
+
91
+ def _envelope(results: list[dict]) -> dict:
92
+ return {"count": len(results), "results": results, "source": _source()}
93
+
94
+
95
+ @mcp.tool()
96
+ def search_by_nip(nip: str) -> dict:
97
+ """Look up a Polish business entity by NIP (10-digit tax id).
98
+
99
+ Returns basic registry data (name, REGON, address, entity type). Use the
100
+ returned REGON with get_full_report() for the detailed record.
101
+ """
102
+ return _envelope(_get_client().search(nip=normalize_nip(nip)))
103
+
104
+
105
+ @mcp.tool()
106
+ def search_by_regon(regon: str) -> dict:
107
+ """Look up a Polish business entity by REGON (9- or 14-digit statistical id)."""
108
+ return _envelope(_get_client().search(regon=normalize_regon(regon)))
109
+
110
+
111
+ @mcp.tool()
112
+ def search_by_krs(krs: str) -> dict:
113
+ """Look up a Polish business entity by KRS (10-digit court-register number)."""
114
+ return _envelope(_get_client().search(krs=normalize_krs(krs)))
115
+
116
+
117
+ @mcp.tool()
118
+ def search_bulk(identifiers: list[str], id_type: str) -> dict:
119
+ """Look up up to 20 entities of the same identifier type in one call.
120
+
121
+ id_type must be one of: "nip", "krs", "regon9", "regon14".
122
+
123
+ The response adds `requested` (normalized inputs) and `unmatched` (requested
124
+ ids with no returned row) so partial results are explicit. For KRS the
125
+ service does not echo the KRS in search rows, so `unmatched` is null there.
126
+ """
127
+ if not identifiers:
128
+ raise ValueError("identifiers must be a non-empty list")
129
+ if len(identifiers) > MAX_BATCH:
130
+ raise ValueError(f"GUS BIR accepts at most {MAX_BATCH} identifiers per bulk search")
131
+
132
+ kind = id_type.lower()
133
+ client = _get_client()
134
+ if kind == "nip":
135
+ requested = [normalize_nip(x) for x in identifiers]
136
+ rows = client.search(nips=requested)
137
+ match_field = "nip"
138
+ elif kind == "krs":
139
+ requested = [normalize_krs(x) for x in identifiers]
140
+ rows = client.search(krss=requested)
141
+ match_field = None # search rows do not include KRS
142
+ elif kind == "regon9":
143
+ requested = [normalize_regon(x) for x in identifiers]
144
+ if any(len(v) != 9 for v in requested):
145
+ raise ValueError("regon9 expects 9-digit REGONs")
146
+ rows = client.search(regons9=requested)
147
+ match_field = "regon"
148
+ elif kind == "regon14":
149
+ requested = [normalize_regon(x) for x in identifiers]
150
+ if any(len(v) != 14 for v in requested):
151
+ raise ValueError("regon14 expects 14-digit REGONs")
152
+ rows = client.search(regons14=requested)
153
+ match_field = "regon"
154
+ else:
155
+ raise ValueError("id_type must be one of: nip, krs, regon9, regon14")
156
+
157
+ envelope = _envelope(rows)
158
+ envelope["requested"] = requested
159
+ if match_field:
160
+ found = {r.get(match_field) for r in rows}
161
+ envelope["unmatched"] = [x for x in requested if x not in found]
162
+ else:
163
+ envelope["unmatched"] = None
164
+ return envelope
165
+
166
+
167
+ @mcp.tool()
168
+ def get_full_report(regon: str, report_type: str) -> dict:
169
+ """Fetch a detailed REGON report for one entity by REGON.
170
+
171
+ Choose report_type from list_report_types(), based on the entity's `type`
172
+ and `silo_id` returned by a search.
173
+ """
174
+ if report_type not in REPORT_TYPES:
175
+ raise ValueError(
176
+ f"Unknown report_type {report_type!r}. Call list_report_types() for valid names."
177
+ )
178
+ return _envelope(_get_client().full_report(normalize_regon(regon), report_type))
179
+
180
+
181
+ @mcp.tool()
182
+ def list_report_types() -> dict:
183
+ """List valid report_type values for get_full_report(), with guidance.
184
+
185
+ These are the BIR1.1 report names. Picking the right report:
186
+ - type "P" (legal person) -> BIR11OsPrawna
187
+ - type "F" (natural person) -> by silo_id:
188
+ 1 -> BIR11OsFizycznaDzialalnoscCeidg
189
+ 2 -> BIR11OsFizycznaDzialalnoscRolnicza
190
+ 3 -> BIR11OsFizycznaDzialalnoscPozostala
191
+ 4 -> BIR11OsFizycznaDzialalnoscSkreslonaDo20141108
192
+ - local units (LP / LF) -> BIR11JednLokalnaOsPrawnej / ...OsFizycznej
193
+ """
194
+ return {
195
+ "report_types": REPORT_TYPES,
196
+ "count": len(REPORT_TYPES),
197
+ "source": _source(),
198
+ }
199
+
200
+
201
+ def main() -> None:
202
+ """Console-script entry point: run the MCP server over stdio."""
203
+ try:
204
+ mcp.run()
205
+ finally:
206
+ if _client is not None:
207
+ _client.close()
regon_mcp/validate.py ADDED
@@ -0,0 +1,84 @@
1
+ """Validation and normalization for Polish business identifiers.
2
+
3
+ Checksums are verified locally so obviously-wrong identifiers fail fast without
4
+ a network round-trip. Only whitespace and common grouping separators (``-`` and
5
+ ``.``) are tolerated in the input; any other character is rejected rather than
6
+ silently stripped, so ``"abc5261040828"`` is an error, not a valid NIP.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # Characters allowed purely as visual separators in a typed identifier.
14
+ _SEPARATORS = re.compile(r"[\s.\-]")
15
+ _ALL_DIGITS = re.compile(r"\A\d+\Z")
16
+
17
+
18
+ def _clean(value: str) -> str:
19
+ """Strip only whitespace/grouping separators; reject anything non-numeric."""
20
+ if value is None:
21
+ raise ValueError("identifier must not be None")
22
+ stripped = _SEPARATORS.sub("", value)
23
+ if not _ALL_DIGITS.match(stripped):
24
+ raise ValueError(
25
+ f"identifier may contain only digits and separators; got {value!r}"
26
+ )
27
+ return stripped
28
+
29
+
30
+ def _reject_all_zeros(digits: str, label: str) -> None:
31
+ if set(digits) == {"0"}:
32
+ raise ValueError(f"{label} {digits} is not a real identifier (all zeros)")
33
+
34
+
35
+ def normalize_nip(nip: str) -> str:
36
+ """Return a clean 10-digit NIP, raising ValueError if it is invalid."""
37
+ d = _clean(nip)
38
+ if len(d) != 10:
39
+ raise ValueError(f"NIP must contain 10 digits; got {len(d)} in {nip!r}")
40
+ _reject_all_zeros(d, "NIP")
41
+ weights = (6, 5, 7, 2, 3, 4, 5, 6, 7)
42
+ check = sum(int(a) * w for a, w in zip(d[:9], weights)) % 11
43
+ if check == 10 or check != int(d[9]):
44
+ raise ValueError(f"NIP {d} has an invalid checksum")
45
+ return d
46
+
47
+
48
+ def _regon9_checksum_ok(d9: str) -> bool:
49
+ weights = (8, 9, 2, 3, 4, 5, 6, 7)
50
+ return sum(int(a) * w for a, w in zip(d9[:8], weights)) % 11 % 10 == int(d9[8])
51
+
52
+
53
+ def normalize_regon(regon: str) -> str:
54
+ """Return a clean 9- or 14-digit REGON, validating every checksum.
55
+
56
+ A 14-digit REGON embeds a 9-digit parent REGON in its first nine digits, so
57
+ both the parent (9-digit) and the full (14-digit) checksums are verified.
58
+ """
59
+ d = _clean(regon)
60
+ if len(d) not in (9, 14):
61
+ raise ValueError(f"REGON must contain 9 or 14 digits; got {len(d)} in {regon!r}")
62
+ _reject_all_zeros(d, "REGON")
63
+ if not _regon9_checksum_ok(d[:9]):
64
+ raise ValueError(f"REGON {d} has an invalid 9-digit (parent) checksum")
65
+ if len(d) == 14:
66
+ weights = (2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8)
67
+ if sum(int(a) * w for a, w in zip(d[:13], weights)) % 11 % 10 != int(d[13]):
68
+ raise ValueError(f"REGON-14 {d} has an invalid 14-digit checksum")
69
+ return d
70
+
71
+
72
+ def normalize_krs(krs: str) -> str:
73
+ """Return a zero-padded 10-digit KRS (no checksum exists).
74
+
75
+ Leading zeros are routinely omitted when a KRS is typed (``543759`` for
76
+ ``0000543759``), so a shorter numeric value is left-padded to 10 digits.
77
+ All-zero and over-long values are rejected.
78
+ """
79
+ d = _clean(krs)
80
+ if not d or len(d) > 10:
81
+ raise ValueError(f"KRS must contain 1 to 10 digits; got {krs!r}")
82
+ padded = d.zfill(10)
83
+ _reject_all_zeros(padded, "KRS")
84
+ return padded
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: regon-mcp
3
+ Version: 0.2.0
4
+ Summary: Model Context Protocol server for the Polish GUS REGON business register (BIR1 API).
5
+ Project-URL: Homepage, https://smartmobilehouse.com
6
+ Project-URL: Repository, https://github.com/SmartMobileHouse/regon-mcp
7
+ Project-URL: Issues, https://github.com/SmartMobileHouse/regon-mcp/issues
8
+ Author: Smart Mobile House
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: bir,company-data,gus,kyc,mcp,model-context-protocol,poland,regon
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Office/Business
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: defusedxml>=0.7
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: mcp<3,>=2.0.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # regon-mcp
22
+
23
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI
24
+ assistants clean, typed access to the **Polish REGON business register** (GUS
25
+ BIR1). Look up any Polish company by **NIP**, **REGON**, or **KRS** and get back
26
+ structured data — name, address, legal form, activity codes — without touching
27
+ the underlying SOAP API.
28
+
29
+ The official [GUS BIR1 API](https://api.stat.gov.pl/Home/RegonApi) is a WCF SOAP
30
+ service with WS-Addressing, MTOM multipart responses, an HTTP-header session
31
+ token, and XML-nested-inside-XML result payloads. `regon-mcp` hides all of that
32
+ behind a handful of simple tools.
33
+
34
+ > Built and maintained by [Smart Mobile House](https://smartmobilehouse.com) —
35
+ > secure AI implementation for enterprise.
36
+
37
+ ## Tools
38
+
39
+ | Tool | Description |
40
+ | --- | --- |
41
+ | `search_by_nip(nip)` | Look up an entity by 10-digit NIP (tax id). |
42
+ | `search_by_regon(regon)` | Look up an entity by 9- or 14-digit REGON. |
43
+ | `search_by_krs(krs)` | Look up an entity by 10-digit KRS (court register). |
44
+ | `search_bulk(identifiers, id_type)` | Look up up to 20 entities of one type at once. |
45
+ | `get_full_report(regon, report_type)` | Fetch a detailed report for one entity. |
46
+ | `list_report_types()` | List valid report names, with guidance on which to use. |
47
+
48
+ Every response includes a `source` block that names the register (REGON / GUS),
49
+ the environment, and a UTC `retrieved_at` timestamp — so downstream use can cite
50
+ the data correctly, as GUS requires.
51
+
52
+ ## Quick start
53
+
54
+ No install needed — run it straight from the repo with
55
+ [uv](https://docs.astral.sh/uv/):
56
+
57
+ ```bash
58
+ uvx --from git+https://github.com/SmartMobileHouse/regon-mcp regon-mcp
59
+ ```
60
+
61
+ By default it uses the **public test key** against the anonymized GUS test
62
+ database, so it runs with zero setup. For live data, request a free `USER_KEY`
63
+ from `regon_bir@stat.gov.pl` and set the environment variables below.
64
+
65
+ ### Use it in Claude Desktop / Claude Code
66
+
67
+ Add to your MCP config (e.g. `claude_desktop_config.json` or a project
68
+ `.mcp.json`):
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "regon": {
74
+ "command": "uvx",
75
+ "args": ["--from", "git+https://github.com/SmartMobileHouse/regon-mcp", "regon-mcp"],
76
+ "env": {
77
+ "REGON_API_KEY": "your-user-key",
78
+ "REGON_ENV": "prod"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ During development, point it at a local checkout instead:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "regon": {
91
+ "command": "uvx",
92
+ "args": ["--from", "/absolute/path/to/regon-mcp", "regon-mcp"],
93
+ "env": { "REGON_API_KEY": "abcde12345abcde12345" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ | Variable | Default | Description |
102
+ | --- | --- | --- |
103
+ | `REGON_API_KEY` | public test key | Your GUS BIR `USER_KEY`. |
104
+ | `REGON_ENV` | `test` | `test` (anonymized data) or `prod` (live data). |
105
+ | `REGON_TIMEOUT` | `30` | HTTP timeout in seconds. |
106
+
107
+ Use `REGON_ENV=prod` only with a real `USER_KEY`; the test key works only
108
+ against the test environment.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ git clone https://github.com/SmartMobileHouse/regon-mcp
114
+ cd regon-mcp
115
+ uv sync # create the venv and install deps
116
+ uv run pytest # offline tests: validation, parsing, mocked client,
117
+ # and an in-memory MCP tool-discovery smoke test.
118
+ # (network tests are deselected by default)
119
+ uv run regon-mcp # run the server over stdio
120
+
121
+ # Live tests against the GUS endpoint (deselected unless opted in):
122
+ REGON_RUN_NETWORK=1 uv run pytest -m network # session lifecycle (test env)
123
+ REGON_PROD_KEY=<your-key> uv run pytest -m network # positive-control on live data
124
+ ```
125
+
126
+ The client is a small hand-rolled SOAP layer over `httpx` (see
127
+ `src/regon_mcp/client.py`) — no heavyweight SOAP stack, no runtime WSDL fetch.
128
+
129
+ ## Notes & limitations
130
+
131
+ - The **GUS test database is anonymized** and returns little or no entity data.
132
+ Meaningful results require a production `USER_KEY` with `REGON_ENV=prod`.
133
+ - Respect the GUS terms of use and rate limits. This project is an independent
134
+ open-source client and is not affiliated with or endorsed by GUS.
135
+ - Data belongs to GUS. When you present it, cite **REGON / GUS** with the
136
+ retrieval date (surfaced in every response's `source` block).
137
+
138
+ ## License
139
+
140
+ [MIT](LICENSE) © Smart Mobile House
@@ -0,0 +1,13 @@
1
+ regon_mcp/__init__.py,sha256=IxgMld53-eyT5692PLJ7LcMVrpLub6JCeeYTtoflcIU,108
2
+ regon_mcp/__main__.py,sha256=9it1my_mM7xBpDh79QSi7FAvZD3Vki3GQFPJIDZw_zs,64
3
+ regon_mcp/client.py,sha256=rabDjtrg5MP4AhOFUkR9OaPKra-e1gZVFJ5HFvZvTyU,11228
4
+ regon_mcp/envelopes.py,sha256=-3WlpQXwrfx6AsWiU2F1LHzToFRNLvQqPGaasuo1FDs,1297
5
+ regon_mcp/errors.py,sha256=kg46-M1eRV0-CO27rUx53R3RZ-1BdQEiLw2vTrRSlCo,723
6
+ regon_mcp/parse.py,sha256=ipojnsCah740fgGvXlnQ4yBX-f983VLLbCN-4P2dXpc,3342
7
+ regon_mcp/server.py,sha256=CRlL8utniCtw-ok3_lDikwiphN-3IdCec5XHxL5P6dc,8039
8
+ regon_mcp/validate.py,sha256=soH1yddg7G5GocJudUHK7pVcdT-1bn2OX2g6Uj0-JrY,3159
9
+ regon_mcp-0.2.0.dist-info/METADATA,sha256=uJUk0QJRnMyF5OnDcGtao_90OMwJQ22zflZFza0iCm4,5052
10
+ regon_mcp-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ regon_mcp-0.2.0.dist-info/entry_points.txt,sha256=RgFuW-CRpGmTjtOCC83-8Z5ti8tULoU8P0klxyG_5fo,52
12
+ regon_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=2vLtjN350BRPGjAPtYoFZ3wJtuSt2VZHxBWGUsAsrl8,1075
13
+ regon_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ regon-mcp = regon_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Smart Mobile House
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.