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.
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()