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 +3 -0
- regon_mcp/__main__.py +4 -0
- regon_mcp/client.py +320 -0
- regon_mcp/envelopes.py +35 -0
- regon_mcp/errors.py +27 -0
- regon_mcp/parse.py +108 -0
- regon_mcp/server.py +207 -0
- regon_mcp/validate.py +84 -0
- regon_mcp-0.2.0.dist-info/METADATA +140 -0
- regon_mcp-0.2.0.dist-info/RECORD +13 -0
- regon_mcp-0.2.0.dist-info/WHEEL +4 -0
- regon_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- regon_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
regon_mcp/__init__.py
ADDED
regon_mcp/__main__.py
ADDED
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,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.
|