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/client.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Asynchronous HTTP client with connection pooling, retries, and path sanitization."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from taxcode_mcp import __version__
|
|
12
|
+
from taxcode_mcp.config import settings
|
|
13
|
+
from taxcode_mcp.errors import (
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ResourceNotFoundError,
|
|
17
|
+
TemplateError,
|
|
18
|
+
redact_secrets,
|
|
19
|
+
)
|
|
20
|
+
from taxcode_mcp.ssrf import SSRFValidationError, validate_outbound_url, validate_resolved_host
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _legacy_base_url_message(exc: SSRFValidationError) -> str:
|
|
24
|
+
"""Map shared SSRF errors onto the base-URL messages callers already match."""
|
|
25
|
+
host = exc.hostname or ""
|
|
26
|
+
if exc.reason in {"scheme", "insecure_http"}:
|
|
27
|
+
return "Only HTTPS is permitted for base URL."
|
|
28
|
+
if exc.reason == "missing_hostname":
|
|
29
|
+
return "Invalid base URL: missing hostname."
|
|
30
|
+
if exc.reason == "blocked_hostname":
|
|
31
|
+
return f"Blocked internal/loopback hostname in base URL: {host}"
|
|
32
|
+
if exc.reason == "allowlist":
|
|
33
|
+
return f"Hostname '{host}' is not in allowed hosts allowlist."
|
|
34
|
+
if exc.reason == "blocked_ip":
|
|
35
|
+
return f"Blocked private/reserved IP address in base URL: {host}"
|
|
36
|
+
return str(exc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_base_url(
|
|
40
|
+
url: str,
|
|
41
|
+
allowed_hosts_str: str | None = None,
|
|
42
|
+
check_dns: bool = False,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Validate a config-controlled base URL via the shared SSRF helper.
|
|
45
|
+
|
|
46
|
+
Template base URLs stay HTTPS-only. Blank input falls back to the configured
|
|
47
|
+
default. DNS rebinding checks stay opt-in here and always run again at
|
|
48
|
+
request time inside ``SSRFSafeAsyncTransport``.
|
|
49
|
+
"""
|
|
50
|
+
if not url:
|
|
51
|
+
return settings.BASE_URL.rstrip("/")
|
|
52
|
+
|
|
53
|
+
allowed_raw = allowed_hosts_str if allowed_hosts_str is not None else settings.ALLOWED_HOSTS
|
|
54
|
+
allowed_hosts = allowed_raw if allowed_raw.strip() else None
|
|
55
|
+
try:
|
|
56
|
+
validated = validate_outbound_url(url, allowed_hosts=allowed_hosts)
|
|
57
|
+
except SSRFValidationError as exc:
|
|
58
|
+
raise ValueError(_legacy_base_url_message(exc)) from exc
|
|
59
|
+
|
|
60
|
+
if check_dns:
|
|
61
|
+
hostname = urlparse(validated).hostname
|
|
62
|
+
if hostname is not None:
|
|
63
|
+
_validate_hostname_dns(hostname)
|
|
64
|
+
return validated.rstrip("/")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validate_hostname_dns(hostname: str) -> None:
|
|
68
|
+
"""Validate resolved DNS IP addresses to defend against private IP binding and DNS rebinding."""
|
|
69
|
+
try:
|
|
70
|
+
validate_resolved_host(hostname)
|
|
71
|
+
except SSRFValidationError as exc:
|
|
72
|
+
if exc.reason == "dns_failure":
|
|
73
|
+
raise ValueError(f"Could not resolve hostname in base URL: {hostname}") from exc
|
|
74
|
+
if exc.resolved_ip is not None:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Blocked hostname '{hostname}' resolving to private/reserved IP: {exc.resolved_ip}"
|
|
77
|
+
) from exc
|
|
78
|
+
if exc.reason == "blocked_ip":
|
|
79
|
+
raise ValueError(f"Blocked private/reserved IP address: {hostname}") from exc
|
|
80
|
+
raise ValueError(str(exc)) from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SSRFSafeAsyncTransport(httpx.AsyncHTTPTransport):
|
|
84
|
+
"""Async HTTP transport enforcing DNS destination validation at request connection time."""
|
|
85
|
+
|
|
86
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
87
|
+
"""Validate destination hostname via worker thread before dispatching HTTP request."""
|
|
88
|
+
hostname = request.url.host
|
|
89
|
+
if hostname:
|
|
90
|
+
try:
|
|
91
|
+
await asyncio.to_thread(_validate_hostname_dns, hostname)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise TemplateError(
|
|
94
|
+
f"SSRF validation blocked request to {hostname}: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
return await super().handle_async_request(request)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TemplateClient:
|
|
100
|
+
"""Hardened async client for interacting with upstream APIs."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
base_url: str | None = None,
|
|
105
|
+
api_key: str | None = None,
|
|
106
|
+
timeout: float | None = None,
|
|
107
|
+
max_retries: int | None = None,
|
|
108
|
+
http_client: httpx.AsyncClient | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
raw_url = (base_url or settings.BASE_URL).rstrip("/")
|
|
111
|
+
self.base_url = _validate_base_url(raw_url, check_dns=False)
|
|
112
|
+
self.api_key = api_key or settings.API_KEY
|
|
113
|
+
self.timeout = timeout if timeout is not None else settings.TIMEOUT_SECONDS
|
|
114
|
+
self.max_retries = max_retries if max_retries is not None else settings.MAX_RETRIES
|
|
115
|
+
self._custom_client = http_client
|
|
116
|
+
self._client: httpx.AsyncClient | None = None
|
|
117
|
+
|
|
118
|
+
async def get_client(self) -> httpx.AsyncClient:
|
|
119
|
+
"""Retrieve or initialize persistent AsyncClient pool."""
|
|
120
|
+
if self._custom_client is not None:
|
|
121
|
+
return self._custom_client
|
|
122
|
+
if self._client is None or self._client.is_closed:
|
|
123
|
+
headers = {
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
"User-Agent": f"mcp-server-taxcode/{__version__}",
|
|
126
|
+
}
|
|
127
|
+
if self.api_key:
|
|
128
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
129
|
+
transport = SSRFSafeAsyncTransport(
|
|
130
|
+
verify=True,
|
|
131
|
+
limits=httpx.Limits(max_keepalive_connections=20, max_connections=50),
|
|
132
|
+
)
|
|
133
|
+
self._client = httpx.AsyncClient(
|
|
134
|
+
base_url=self.base_url,
|
|
135
|
+
headers=headers,
|
|
136
|
+
timeout=httpx.Timeout(self.timeout),
|
|
137
|
+
transport=transport,
|
|
138
|
+
)
|
|
139
|
+
return self._client
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Close underlying connection pool."""
|
|
143
|
+
if self._client and not self._client.is_closed:
|
|
144
|
+
await self._client.aclose()
|
|
145
|
+
|
|
146
|
+
def sanitize_path_param(self, segment: str) -> str:
|
|
147
|
+
"""Encode path parameters to prevent directory traversal and path injection."""
|
|
148
|
+
return urllib.parse.quote(str(segment), safe="").replace("..", "%2E%2E")
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def extract_list(container: Any, key: str | None = None) -> list[Any]:
|
|
152
|
+
"""Safely extract a list from a polymorphic upstream JSON response or field."""
|
|
153
|
+
if key is not None:
|
|
154
|
+
target = container.get(key) if isinstance(container, dict) else None
|
|
155
|
+
else:
|
|
156
|
+
target = container
|
|
157
|
+
return target if isinstance(target, list) else []
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def extract_dict(container: Any, key: str | None = None) -> dict[str, Any]:
|
|
161
|
+
"""Safely extract a dictionary from a polymorphic upstream JSON response or field."""
|
|
162
|
+
if key is not None:
|
|
163
|
+
target = container.get(key) if isinstance(container, dict) else None
|
|
164
|
+
else:
|
|
165
|
+
target = container
|
|
166
|
+
return target if isinstance(target, dict) else {}
|
|
167
|
+
|
|
168
|
+
async def request(
|
|
169
|
+
self,
|
|
170
|
+
method: str,
|
|
171
|
+
path: str,
|
|
172
|
+
params: dict[str, Any] | None = None,
|
|
173
|
+
json_data: dict[str, Any] | None = None,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Execute request with exponential backoff and randomized jitter."""
|
|
176
|
+
client = await self.get_client()
|
|
177
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
178
|
+
last_exception: Exception | None = None
|
|
179
|
+
|
|
180
|
+
for attempt in range(self.max_retries + 1):
|
|
181
|
+
try:
|
|
182
|
+
response = await client.request(
|
|
183
|
+
method=method,
|
|
184
|
+
url=url,
|
|
185
|
+
params=params,
|
|
186
|
+
json=json_data,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if response.status_code in (401, 403):
|
|
190
|
+
raise AuthenticationError(f"HTTP {response.status_code}: {response.text}")
|
|
191
|
+
if response.status_code == 404:
|
|
192
|
+
raise ResourceNotFoundError(f"HTTP 404: Resource at '{path}' not found.")
|
|
193
|
+
if response.status_code == 429:
|
|
194
|
+
if attempt < self.max_retries:
|
|
195
|
+
backoff = (2**attempt) + random.uniform(0.1, 0.5)
|
|
196
|
+
await asyncio.sleep(backoff)
|
|
197
|
+
continue
|
|
198
|
+
raise RateLimitError("HTTP 429: Rate limit exceeded after retries.")
|
|
199
|
+
|
|
200
|
+
if response.status_code >= 500:
|
|
201
|
+
if attempt < self.max_retries:
|
|
202
|
+
backoff = (2**attempt) + random.uniform(0.1, 0.5)
|
|
203
|
+
await asyncio.sleep(backoff)
|
|
204
|
+
continue
|
|
205
|
+
raise TemplateError(f"HTTP {response.status_code}: Upstream server error.")
|
|
206
|
+
|
|
207
|
+
response.raise_for_status()
|
|
208
|
+
if not response.content:
|
|
209
|
+
return {}
|
|
210
|
+
return response.json() # type: ignore[no-any-return]
|
|
211
|
+
|
|
212
|
+
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
|
|
213
|
+
last_exception = exc
|
|
214
|
+
if attempt < self.max_retries:
|
|
215
|
+
await asyncio.sleep((2**attempt) + random.uniform(0.1, 0.5))
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
raise TemplateError(f"Request failed: {redact_secrets(str(last_exception))}")
|
taxcode_mcp/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Configuration management for template MCP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
"""Application settings with environment variable bindings."""
|
|
11
|
+
|
|
12
|
+
model_config = SettingsConfigDict(
|
|
13
|
+
env_prefix="TAXCODE_",
|
|
14
|
+
env_file=".env",
|
|
15
|
+
extra="ignore",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
API_KEY: str = Field(default="", description="Service API token")
|
|
19
|
+
BASE_URL: str = Field(default="https://api.example.com", description="Target REST API base URL")
|
|
20
|
+
ALLOWED_HOSTS: str = Field(
|
|
21
|
+
default="",
|
|
22
|
+
description="Comma-separated hostname allowlist for SSRF defense",
|
|
23
|
+
)
|
|
24
|
+
TIMEOUT_SECONDS: float = Field(
|
|
25
|
+
default=30.0,
|
|
26
|
+
gt=0,
|
|
27
|
+
description="HTTP request timeout in seconds",
|
|
28
|
+
)
|
|
29
|
+
MAX_RETRIES: int = Field(
|
|
30
|
+
default=3,
|
|
31
|
+
ge=0,
|
|
32
|
+
le=10,
|
|
33
|
+
description="Maximum retry attempts on HTTP 429 / 5xx",
|
|
34
|
+
)
|
|
35
|
+
DB_PATH: str = Field(
|
|
36
|
+
default=":memory:",
|
|
37
|
+
description="Path to DuckDB database file, or ':memory:' for transient storage",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Safety Gating
|
|
41
|
+
MCP_READONLY: bool = Field(
|
|
42
|
+
default=False,
|
|
43
|
+
description="Restrict server strictly to tools marked readOnlyHint=True",
|
|
44
|
+
)
|
|
45
|
+
MCP_ALLOW_BULK_DESTRUCTIVE: bool = Field(
|
|
46
|
+
default=False,
|
|
47
|
+
description="Gate required to register and execute batch destructive mutations",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Transport Options (Spec 2026-07-28 / SEP-1049)
|
|
51
|
+
MCP_STATELESS_HTTP: bool = Field(
|
|
52
|
+
default=False,
|
|
53
|
+
description="Enable stateless Streamable HTTP transport mode (Spec 2026-07-28 / SEP-1049)",
|
|
54
|
+
)
|
|
55
|
+
MCP_JSON_RESPONSE: bool = Field(
|
|
56
|
+
default=False,
|
|
57
|
+
description="Return direct application/json responses instead of SSE text/event-stream",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Composition and Discovery
|
|
61
|
+
MCP_ENABLE_TOOL_SEARCH: bool = Field(
|
|
62
|
+
default=False,
|
|
63
|
+
description=(
|
|
64
|
+
"Opt-in Tool Search transform (search_tools + call_tool). "
|
|
65
|
+
"Attached only when profile is 'full'."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
MCP_TOOL_SEARCH_BACKEND: Literal["regex", "bm25"] = Field(
|
|
69
|
+
default="regex",
|
|
70
|
+
description="Tool Search backend: 'regex' (default) or 'bm25'",
|
|
71
|
+
)
|
|
72
|
+
MCP_ENABLE_CODE_MODE: bool = Field(
|
|
73
|
+
default=False,
|
|
74
|
+
description=(
|
|
75
|
+
"Opt-in experimental Code Mode transform (search + execute). "
|
|
76
|
+
"Attached only when profile is 'full'; mutually exclusive with Tool Search."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
MCP_PROFILE: str = Field(
|
|
81
|
+
default="full",
|
|
82
|
+
description=(
|
|
83
|
+
"Server profile: domain-mount profiles 'full', 'system', 'items', 'readonly', "
|
|
84
|
+
"or a tool-name allowlist profile such as 'intake' (see profiles.PROFILES)"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@field_validator("MCP_TOOL_SEARCH_BACKEND", mode="before")
|
|
89
|
+
@classmethod
|
|
90
|
+
def _normalize_tool_search_backend(cls, value: Any) -> Any:
|
|
91
|
+
"""Accept ``BM25`` or `` bm25 `` from the env; unknown values still fail the Literal."""
|
|
92
|
+
return value.strip().lower() if isinstance(value, str) else value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
settings = Settings()
|