mcp-server-taxcode 0.1.1__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.
- mcp_server_taxcode-0.1.1.dist-info/METADATA +307 -0
- mcp_server_taxcode-0.1.1.dist-info/RECORD +19 -0
- mcp_server_taxcode-0.1.1.dist-info/WHEEL +4 -0
- mcp_server_taxcode-0.1.1.dist-info/entry_points.txt +3 -0
- mcp_server_taxcode-0.1.1.dist-info/licenses/LICENSE +190 -0
- taxcode_mcp/__init__.py +14 -0
- taxcode_mcp/auth.py +65 -0
- taxcode_mcp/client.py +218 -0
- taxcode_mcp/config.py +95 -0
- taxcode_mcp/db.py +1450 -0
- taxcode_mcp/errors.py +399 -0
- taxcode_mcp/middleware.py +169 -0
- taxcode_mcp/profiles.py +180 -0
- taxcode_mcp/server.py +526 -0
- taxcode_mcp/ssrf.py +287 -0
- taxcode_mcp/tools/__init__.py +41 -0
- taxcode_mcp/tools/compliance.py +542 -0
- taxcode_mcp/tools/statutes.py +156 -0
- taxcode_mcp/tools/system.py +72 -0
taxcode_mcp/ssrf.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Shared SSRF checks for caller- or config-controlled outbound URLs.
|
|
2
|
+
|
|
3
|
+
Leaf servers that fetch a URL supplied by a tool argument or by configuration
|
|
4
|
+
should call :func:`validate_outbound_url` before the request. Rejection raises
|
|
5
|
+
:class:`SSRFValidationError`; it never returns the original URL unchanged after
|
|
6
|
+
a failed check.
|
|
7
|
+
|
|
8
|
+
HTTPS is required unless the host is listed in ``http_required_hosts``. Those
|
|
9
|
+
hosts are the only ones allowed to use ``http://``, and when ``allowed_hosts``
|
|
10
|
+
is also set the host must appear there too. Literal private, loopback,
|
|
11
|
+
link-local, multicast, reserved, and metadata addresses are blocked, including
|
|
12
|
+
obscured IPv4 forms (decimal, octal, hex, and short dotted forms).
|
|
13
|
+
|
|
14
|
+
DNS rebinding is a separate, request-time check. Use
|
|
15
|
+
:func:`validate_resolved_host` (off the event loop; see
|
|
16
|
+
``SSRFSafeAsyncTransport``) so a public name cannot resolve to an internal
|
|
17
|
+
address at connect time.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ipaddress
|
|
23
|
+
import re
|
|
24
|
+
import socket
|
|
25
|
+
from collections.abc import Collection
|
|
26
|
+
from typing import Literal
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
SSRFReason = Literal[
|
|
30
|
+
"empty",
|
|
31
|
+
"invalid",
|
|
32
|
+
"scheme",
|
|
33
|
+
"missing_hostname",
|
|
34
|
+
"userinfo",
|
|
35
|
+
"blocked_hostname",
|
|
36
|
+
"blocked_ip",
|
|
37
|
+
"invalid_ip",
|
|
38
|
+
"allowlist",
|
|
39
|
+
"insecure_http",
|
|
40
|
+
"dns_failure",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_BLOCKED_HOSTS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"localhost",
|
|
46
|
+
"localhost.localdomain",
|
|
47
|
+
"metadata.google.internal",
|
|
48
|
+
"metadata.goog",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
_BLOCKED_SUFFIXES = (".localhost", ".local", ".internal", ".localdomain")
|
|
52
|
+
|
|
53
|
+
_OBSCURE_IPV4_RE = re.compile(
|
|
54
|
+
r"(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+)){0,3}",
|
|
55
|
+
flags=re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
_IPV4_WIDTHS: dict[int, tuple[int, ...]] = {
|
|
58
|
+
1: (32,),
|
|
59
|
+
2: (8, 24),
|
|
60
|
+
3: (8, 8, 16),
|
|
61
|
+
4: (8, 8, 8, 8),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SSRFValidationError(ValueError):
|
|
66
|
+
"""Raised when an outbound URL or resolved address fails SSRF validation."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
reason: SSRFReason,
|
|
73
|
+
hostname: str | None = None,
|
|
74
|
+
resolved_ip: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.reason = reason
|
|
78
|
+
self.hostname = hostname
|
|
79
|
+
self.resolved_ip = resolved_ip
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_outbound_url(
|
|
83
|
+
url: str,
|
|
84
|
+
*,
|
|
85
|
+
allowed_hosts: Collection[str] | str | None = None,
|
|
86
|
+
http_required_hosts: Collection[str] | str | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Validate ``url`` and return it stripped of surrounding whitespace.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
url: Absolute URL to fetch. Must be ``http`` or ``https``.
|
|
92
|
+
allowed_hosts: Optional leaf-specific host allowlist. ``None`` does not
|
|
93
|
+
restrict the host. A string is split on commas. An empty allowlist
|
|
94
|
+
matches nothing.
|
|
95
|
+
http_required_hosts: Hosts that must be contacted with ``http://``.
|
|
96
|
+
Every other host must use ``https://``. When ``allowed_hosts`` is
|
|
97
|
+
set, an HTTP host must be present in both lists.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
SSRFValidationError: The URL is unsafe. The exception message states
|
|
101
|
+
why; callers must not treat a raise as success.
|
|
102
|
+
"""
|
|
103
|
+
candidate = url.strip()
|
|
104
|
+
if not candidate:
|
|
105
|
+
raise SSRFValidationError("Outbound URL is empty.", reason="empty")
|
|
106
|
+
if "\\" in candidate or any(ord(char) < 32 for char in candidate):
|
|
107
|
+
raise SSRFValidationError(
|
|
108
|
+
"Outbound URL contains illegal characters.",
|
|
109
|
+
reason="invalid",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
parsed = urlparse(candidate)
|
|
113
|
+
scheme = parsed.scheme.lower()
|
|
114
|
+
if scheme not in {"http", "https"}:
|
|
115
|
+
shown = scheme or "(none)"
|
|
116
|
+
raise SSRFValidationError(
|
|
117
|
+
f"Outbound URL scheme {shown!r} is not permitted; only http and https are allowed.",
|
|
118
|
+
reason="scheme",
|
|
119
|
+
)
|
|
120
|
+
if parsed.username is not None or parsed.password is not None:
|
|
121
|
+
raise SSRFValidationError(
|
|
122
|
+
"Outbound URL must not include userinfo credentials.",
|
|
123
|
+
reason="userinfo",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
raw_host = parsed.hostname
|
|
127
|
+
if not raw_host:
|
|
128
|
+
raise SSRFValidationError(
|
|
129
|
+
"Outbound URL is missing a hostname.",
|
|
130
|
+
reason="missing_hostname",
|
|
131
|
+
)
|
|
132
|
+
host = raw_host.rstrip(".").lower()
|
|
133
|
+
if host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES):
|
|
134
|
+
raise SSRFValidationError(
|
|
135
|
+
f"Blocked internal, loopback, or metadata hostname: {host}",
|
|
136
|
+
reason="blocked_hostname",
|
|
137
|
+
hostname=host,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
literal = _literal_ip(host)
|
|
141
|
+
if literal is not None and _ip_is_blocked(literal):
|
|
142
|
+
raise SSRFValidationError(
|
|
143
|
+
f"Blocked private, link-local, loopback, or metadata address: {host}",
|
|
144
|
+
reason="blocked_ip",
|
|
145
|
+
hostname=host,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
permitted = _host_set(allowed_hosts)
|
|
149
|
+
if permitted is not None and host not in permitted:
|
|
150
|
+
raise SSRFValidationError(
|
|
151
|
+
f"Hostname {host!r} is not in the outbound host allowlist.",
|
|
152
|
+
reason="allowlist",
|
|
153
|
+
hostname=host,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if scheme == "http":
|
|
157
|
+
http_hosts = _host_set(http_required_hosts)
|
|
158
|
+
if http_hosts is None or host not in http_hosts:
|
|
159
|
+
raise SSRFValidationError(
|
|
160
|
+
"Outbound URL must use HTTPS. HTTP is permitted only for an explicitly "
|
|
161
|
+
"configured allowlisted host.",
|
|
162
|
+
reason="insecure_http",
|
|
163
|
+
hostname=host,
|
|
164
|
+
)
|
|
165
|
+
return candidate
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate_resolved_host(hostname: str) -> None:
|
|
169
|
+
"""Reject a hostname that is, or resolves to, a non-global address.
|
|
170
|
+
|
|
171
|
+
``example.com`` and names under it skip DNS so placeholder templates stay
|
|
172
|
+
usable offline. Run this in a worker thread when calling from an event loop.
|
|
173
|
+
"""
|
|
174
|
+
host = hostname.rstrip(".").lower()
|
|
175
|
+
if host == "example.com" or host.endswith(".example.com"):
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
literal = _literal_ip(host)
|
|
179
|
+
if literal is not None:
|
|
180
|
+
if _ip_is_blocked(literal):
|
|
181
|
+
raise SSRFValidationError(
|
|
182
|
+
f"Blocked private, link-local, loopback, or metadata address: {host}",
|
|
183
|
+
reason="blocked_ip",
|
|
184
|
+
hostname=host,
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
resolved = socket.getaddrinfo(host, None)
|
|
190
|
+
except socket.gaierror as exc:
|
|
191
|
+
raise SSRFValidationError(
|
|
192
|
+
f"Could not resolve hostname: {host}",
|
|
193
|
+
reason="dns_failure",
|
|
194
|
+
hostname=host,
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
197
|
+
found = False
|
|
198
|
+
for _, _, _, _, sockaddr in resolved:
|
|
199
|
+
found = True
|
|
200
|
+
resolved_ip = ipaddress.ip_address(sockaddr[0])
|
|
201
|
+
if _ip_is_blocked(resolved_ip):
|
|
202
|
+
raise SSRFValidationError(
|
|
203
|
+
f"Blocked hostname {host!r} resolving to a non-global address: {sockaddr[0]}",
|
|
204
|
+
reason="blocked_ip",
|
|
205
|
+
hostname=host,
|
|
206
|
+
resolved_ip=str(sockaddr[0]),
|
|
207
|
+
)
|
|
208
|
+
if not found:
|
|
209
|
+
raise SSRFValidationError(
|
|
210
|
+
f"Could not resolve hostname: {host}",
|
|
211
|
+
reason="dns_failure",
|
|
212
|
+
hostname=host,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _host_set(hosts: Collection[str] | str | None) -> frozenset[str] | None:
|
|
217
|
+
"""Normalize an allowlist. ``None`` means unrestricted; a string is comma-separated."""
|
|
218
|
+
if hosts is None:
|
|
219
|
+
return None
|
|
220
|
+
items = hosts.split(",") if isinstance(hosts, str) else hosts
|
|
221
|
+
return frozenset(item.strip().lower().rstrip(".") for item in items if item.strip())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
225
|
+
"""Return True for addresses that must not be used as outbound destinations.
|
|
226
|
+
|
|
227
|
+
Multicast can report ``is_global`` in this Python, so it is checked explicitly.
|
|
228
|
+
Shared address space such as ``100.64.0.0/10`` is not marked private and is
|
|
229
|
+
rejected because it is not globally routable.
|
|
230
|
+
"""
|
|
231
|
+
return bool(
|
|
232
|
+
ip.is_private
|
|
233
|
+
or ip.is_loopback
|
|
234
|
+
or ip.is_link_local
|
|
235
|
+
or ip.is_multicast
|
|
236
|
+
or ip.is_reserved
|
|
237
|
+
or ip.is_unspecified
|
|
238
|
+
or not ip.is_global
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _literal_ip(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
243
|
+
"""Parse a hostname that is an IP literal, including obscured IPv4 forms."""
|
|
244
|
+
try:
|
|
245
|
+
return ipaddress.ip_address(hostname)
|
|
246
|
+
except ValueError:
|
|
247
|
+
return _obscure_ipv4(hostname)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _obscure_ipv4(text: str) -> ipaddress.IPv4Address | None:
|
|
251
|
+
"""Decode decimal, octal, hex, and short-form IPv4 literals.
|
|
252
|
+
|
|
253
|
+
Returns ``None`` when ``text`` is not shaped like an IPv4 literal. Raises
|
|
254
|
+
when it is shaped like one but the value is not a legal address, so those
|
|
255
|
+
forms cannot fall through as ordinary hostnames.
|
|
256
|
+
"""
|
|
257
|
+
if _OBSCURE_IPV4_RE.fullmatch(text) is None:
|
|
258
|
+
return None
|
|
259
|
+
parts = text.split(".")
|
|
260
|
+
widths = _IPV4_WIDTHS[len(parts)]
|
|
261
|
+
packed = 0
|
|
262
|
+
for part, width in zip(parts, widths, strict=True):
|
|
263
|
+
try:
|
|
264
|
+
value = _parse_ipv4_component(part)
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
raise SSRFValidationError(
|
|
267
|
+
f"Outbound URL hostname is not a valid IP address: {text}",
|
|
268
|
+
reason="invalid_ip",
|
|
269
|
+
hostname=text,
|
|
270
|
+
) from exc
|
|
271
|
+
if value >= (1 << width):
|
|
272
|
+
raise SSRFValidationError(
|
|
273
|
+
f"Outbound URL hostname is not a valid IP address: {text}",
|
|
274
|
+
reason="invalid_ip",
|
|
275
|
+
hostname=text,
|
|
276
|
+
)
|
|
277
|
+
packed = (packed << width) | value
|
|
278
|
+
return ipaddress.IPv4Address(packed)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_ipv4_component(part: str) -> int:
|
|
282
|
+
"""Parse one IPv4 component, honoring ``0x`` hex and leading-zero octal."""
|
|
283
|
+
if part.lower().startswith("0x"):
|
|
284
|
+
return int(part, 16)
|
|
285
|
+
if len(part) > 1 and part.startswith("0"):
|
|
286
|
+
return int(part, 8)
|
|
287
|
+
return int(part, 10)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Modular domain tools and sub-servers for FastMCP 4 Server Composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from taxcode_mcp.tools.compliance import (
|
|
6
|
+
calculate_built_in_gain_sourcing,
|
|
7
|
+
compliance_server,
|
|
8
|
+
cross_border_tax_analysis,
|
|
9
|
+
evaluate_georgia_residency,
|
|
10
|
+
evaluate_pr_bona_fide_residency,
|
|
11
|
+
evaluate_state_residency,
|
|
12
|
+
)
|
|
13
|
+
from taxcode_mcp.tools.statutes import (
|
|
14
|
+
get_statute_resource,
|
|
15
|
+
list_publications,
|
|
16
|
+
lookup_statute,
|
|
17
|
+
search_tax_code,
|
|
18
|
+
statutes_server,
|
|
19
|
+
)
|
|
20
|
+
from taxcode_mcp.tools.system import (
|
|
21
|
+
get_capabilities,
|
|
22
|
+
get_health_status,
|
|
23
|
+
system_server,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"calculate_built_in_gain_sourcing",
|
|
28
|
+
"compliance_server",
|
|
29
|
+
"cross_border_tax_analysis",
|
|
30
|
+
"evaluate_georgia_residency",
|
|
31
|
+
"evaluate_pr_bona_fide_residency",
|
|
32
|
+
"evaluate_state_residency",
|
|
33
|
+
"get_capabilities",
|
|
34
|
+
"get_health_status",
|
|
35
|
+
"get_statute_resource",
|
|
36
|
+
"list_publications",
|
|
37
|
+
"lookup_statute",
|
|
38
|
+
"search_tax_code",
|
|
39
|
+
"statutes_server",
|
|
40
|
+
"system_server",
|
|
41
|
+
]
|