fxmacrodata-public-client 0.1.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Tidball
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.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: fxmacrodata-public-client
3
+ Version: 0.1.1
4
+ Summary: Public FXMacroData REST and MCP client for native integrations
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://fxmacrodata.com/?utm_source=pypi&utm_medium=referral&utm_campaign=public-client&utm_content=homepage
7
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=public-client&utm_content=documentation
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: requests<3,>=2.32
12
+ Requires-Dist: jsonschema<5,>=4.23
13
+ Requires-Dist: certifi>=2026.10.23
14
+ Dynamic: license-file
15
+
16
+ # FXMacroData public integration client
17
+
18
+ Connect Python applications and native integrations to FXMacroData's official macroeconomic history, release calendars and market data through one REST and MCP client. Results retain the original response and provide an additional tabular view.
19
+
20
+ [Subscribe to FXMacroData](https://fxmacrodata.com/subscribe?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_subscribe) for non-USD indicator history, full available history, FX, commodities and positioning. Use the public USD workflow to evaluate the integration before connecting your subscription.
21
+
22
+ The packaged discovery snapshot includes 29 documented REST operations and 50 hosted MCP tools. MCP discovery can refresh the tool inventory. The research-panel operation accepts its documented JSON request as `body`; all other REST arguments retain their documented parameter names.
23
+
24
+ Read [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_docs) or visit [FXMacroData](https://fxmacrodata.com/?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_readme).
25
+
26
+ Install from the package directory or its built wheel:
27
+
28
+ ```sh
29
+ python -m pip install .
30
+ ```
31
+
32
+ ```python
33
+ from fxmacrodata_public import FXMacroDataClient
34
+
35
+ with FXMacroDataClient() as client:
36
+ result = client.execute("release_calendar", {"currency": "usd"})
37
+ rows = result.records()
38
+ original_response = result.payload
39
+ ```
40
+
41
+ For evaluation, the USD catalogue, recent macro history and calendar work without an API key. Anonymous indicator history currently covers a rolling 90-day window. Set `FXMACRODATA_API_KEY` or pass `api_key` to the constructor to connect your subscription for protected data access. Credentials are excluded from operation schemas and errors. Requests send X-API-Key only in headers, without following redirects. There are no tracking calls; attribution parameters appear only on website links.
42
+
43
+ The client redacts credential echoes in results and its requests/urllib3 diagnostics. Its own HTTPS connections keep wire debugging disabled even when the application enables the global `http.client` debug flag. Injected sessions remain the caller's responsibility. Keep credentials in user-managed environment variables or a host secret store; never put them in prompts or shared workflow files.
44
+
45
+ Use `list_operations()` for all REST and MCP input schemas; MCP operation names begin with `mcp_`. For example, `client.execute("mcp_seasonality", {"instrument": "eur_usd"})` calls the hosted seasonality tool when the account has access. `client.discover_mcp_tools()` refreshes the hosted catalogue.
46
+
47
+ `stream_events` is a finite capture bounded by `max_events` and `max_seconds`. It retains SSE IDs, event types and original data. No events does not imply there were no economic releases. The records projection is for presentation; use `.payload` whenever the complete response metadata or original nesting is required.
48
+
49
+ Forecast sources, units, dates and publication timestamps are preserved. FX reference rates are not executable broker quotes. The client never infers missing values or future release dates.
50
+
51
+ For historical research, use `history_path` with `revisions="all"`,
52
+ `series_mode="raw"` and `value_mode="source"`. Every page must explicitly confirm
53
+ source values and no normalization; unsupported responses raise an error.
54
+ `align_macro` selects only independently evidenced vintages before each decision,
55
+ retains the original reference period and excludes unknown publication timing.
56
+ Always inspect source units before combining different indicators or countries.
@@ -0,0 +1,41 @@
1
+ # FXMacroData public integration client
2
+
3
+ Connect Python applications and native integrations to FXMacroData's official macroeconomic history, release calendars and market data through one REST and MCP client. Results retain the original response and provide an additional tabular view.
4
+
5
+ [Subscribe to FXMacroData](https://fxmacrodata.com/subscribe?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_subscribe) for non-USD indicator history, full available history, FX, commodities and positioning. Use the public USD workflow to evaluate the integration before connecting your subscription.
6
+
7
+ The packaged discovery snapshot includes 29 documented REST operations and 50 hosted MCP tools. MCP discovery can refresh the tool inventory. The research-panel operation accepts its documented JSON request as `body`; all other REST arguments retain their documented parameter names.
8
+
9
+ Read [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_docs) or visit [FXMacroData](https://fxmacrodata.com/?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_readme).
10
+
11
+ Install from the package directory or its built wheel:
12
+
13
+ ```sh
14
+ python -m pip install .
15
+ ```
16
+
17
+ ```python
18
+ from fxmacrodata_public import FXMacroDataClient
19
+
20
+ with FXMacroDataClient() as client:
21
+ result = client.execute("release_calendar", {"currency": "usd"})
22
+ rows = result.records()
23
+ original_response = result.payload
24
+ ```
25
+
26
+ For evaluation, the USD catalogue, recent macro history and calendar work without an API key. Anonymous indicator history currently covers a rolling 90-day window. Set `FXMACRODATA_API_KEY` or pass `api_key` to the constructor to connect your subscription for protected data access. Credentials are excluded from operation schemas and errors. Requests send X-API-Key only in headers, without following redirects. There are no tracking calls; attribution parameters appear only on website links.
27
+
28
+ The client redacts credential echoes in results and its requests/urllib3 diagnostics. Its own HTTPS connections keep wire debugging disabled even when the application enables the global `http.client` debug flag. Injected sessions remain the caller's responsibility. Keep credentials in user-managed environment variables or a host secret store; never put them in prompts or shared workflow files.
29
+
30
+ Use `list_operations()` for all REST and MCP input schemas; MCP operation names begin with `mcp_`. For example, `client.execute("mcp_seasonality", {"instrument": "eur_usd"})` calls the hosted seasonality tool when the account has access. `client.discover_mcp_tools()` refreshes the hosted catalogue.
31
+
32
+ `stream_events` is a finite capture bounded by `max_events` and `max_seconds`. It retains SSE IDs, event types and original data. No events does not imply there were no economic releases. The records projection is for presentation; use `.payload` whenever the complete response metadata or original nesting is required.
33
+
34
+ Forecast sources, units, dates and publication timestamps are preserved. FX reference rates are not executable broker quotes. The client never infers missing values or future release dates.
35
+
36
+ For historical research, use `history_path` with `revisions="all"`,
37
+ `series_mode="raw"` and `value_mode="source"`. Every page must explicitly confirm
38
+ source values and no normalization; unsupported responses raise an error.
39
+ `align_macro` selects only independently evidenced vintages before each decision,
40
+ retains the original reference period and excludes unknown publication timing.
41
+ Always inspect source units before combining different indicators or countries.
@@ -0,0 +1,8 @@
1
+ """Documented public client contracts for FXMacroData integrations."""
2
+ from .client import FXMacroDataClient, FXMacroDataError, Operation, Result, list_operations
3
+ from .pagination import IncompleteHistoryError, DatasetChangedError
4
+ from .research import align_macro, macro_events, macro_updates, prepare_events, PointInTimeError
5
+
6
+ __all__ = ["FXMacroDataClient", "FXMacroDataError", "Operation", "Result", "list_operations",
7
+ "IncompleteHistoryError", "DatasetChangedError", "align_macro", "macro_events",
8
+ "macro_updates", "prepare_events", "PointInTimeError"]
@@ -0,0 +1,517 @@
1
+ """Bounded REST and MCP access to the public FXMacroData service."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ from importlib.resources import files
8
+ import json
9
+ import math
10
+ import os
11
+ import re
12
+ import socket
13
+ import threading
14
+ import time
15
+ from typing import Any, Iterator
16
+ from urllib.parse import quote
17
+
18
+ import jsonschema
19
+ from referencing import Registry
20
+ from referencing.exceptions import NoSuchResource, Unresolvable
21
+ import requests
22
+ from .transport import CredentialSafeAdapter, protected_diagnostics, redact_text
23
+ from .pagination import collect_history, paginated_history_path
24
+
25
+ API_ORIGIN = "https://api.fxmacrodata.com"
26
+ MCP_URL = "https://mcp.fxmacrodata.com/mcp"
27
+ WEBSITE = "https://fxmacrodata.com"
28
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
29
+ MCP_PROTOCOL_VERSION = "2025-03-26"
30
+
31
+
32
+ def _no_remote_schema(uri: str) -> Any:
33
+ """Input validation must never fetch a schema from another network origin."""
34
+ raise NoSuchResource(ref=uri)
35
+
36
+
37
+ SCHEMA_REGISTRY = Registry(retrieve=_no_remote_schema)
38
+
39
+
40
+ class FXMacroDataError(RuntimeError):
41
+ """An actionable error which never includes request URLs or credentials."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Operation:
46
+ name: str
47
+ description: str
48
+ input_schema: dict[str, Any]
49
+ path: str
50
+ method: str
51
+ parameters: tuple[dict[str, Any], ...] = field(default_factory=tuple)
52
+
53
+
54
+ def list_operations(include_mcp: bool = True) -> tuple[Operation, ...]:
55
+ """Return the complete packaged public operation inventory and input schemas."""
56
+ raw = json.loads(files(__package__).joinpath("operations.json").read_text(encoding="utf-8"))
57
+ operations = [Operation(**{**item, "parameters": tuple(item.get("parameters", []))}) for item in raw]
58
+ if include_mcp:
59
+ tools = json.loads(files(__package__).joinpath("mcp-tools.json").read_text(encoding="utf-8"))
60
+ operations.extend(Operation("mcp_" + tool["name"], tool.get("description", tool["name"]), tool["inputSchema"], "/mcp", "MCP") for tool in tools)
61
+ return tuple(operations)
62
+
63
+
64
+ def _records(payload: Any) -> list[dict[str, Any]]:
65
+ if isinstance(payload, list):
66
+ return [deepcopy(row) if isinstance(row, dict) else {"value": deepcopy(row)} for row in payload]
67
+ if not isinstance(payload, dict):
68
+ return [] if payload is None else [{"value": deepcopy(payload)}]
69
+ for key in ("structuredContent", "result", "data", "rows", "events", "announcements", "predictions", "prices", "commodities", "results", "series", "indicators"):
70
+ if key in payload and isinstance(payload[key], (dict, list)):
71
+ value = payload[key]
72
+ if isinstance(value, dict) and value and all(isinstance(v, dict) for v in value.values()):
73
+ return [{"record_key": k, "record": deepcopy(v)} for k, v in value.items()]
74
+ return _records(value)
75
+ # MCP text is rendered as text unless it contains a structured JSON result.
76
+ if isinstance(payload.get("content"), list):
77
+ rows = []
78
+ for block in payload["content"]:
79
+ if isinstance(block, dict) and block.get("type") == "text":
80
+ try:
81
+ rows.extend(_records(json.loads(block["text"])))
82
+ except (ValueError, KeyError):
83
+ rows.append({"text": block.get("text", "")})
84
+ elif isinstance(block, dict):
85
+ rows.append(deepcopy(block))
86
+ return rows
87
+ return [deepcopy(payload)] if payload else []
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Result:
92
+ """Original response plus an additive tabular view; no source fields renamed."""
93
+ operation: str
94
+ payload: Any
95
+ source_url: str = WEBSITE
96
+
97
+ def records(self) -> list[dict[str, Any]]:
98
+ return _records(self.payload)
99
+
100
+ def as_dict(self) -> dict[str, Any]:
101
+ return {"operation": self.operation, "data": deepcopy(self.payload), "records": self.records(), "source_url": self.source_url}
102
+
103
+
104
+ class FXMacroDataClient:
105
+ """Fixed-origin client with user-supplied authentication and safe errors.
106
+
107
+ API keys belong in the constructor or FXMACRODATA_API_KEY / FXMD_API_KEY.
108
+ They are never model-visible operation arguments. This client emits no telemetry.
109
+ """
110
+
111
+ def __init__(self, api_key: str | None = None, timeout: float = 30, *, session: requests.Session | None = None):
112
+ if api_key is not None and not isinstance(api_key, str):
113
+ raise FXMacroDataError("The optional API key must be a string.")
114
+ try:
115
+ timeout_value = float(timeout)
116
+ if not math.isfinite(timeout_value):
117
+ raise ValueError
118
+ except (ValueError, TypeError, OverflowError):
119
+ raise FXMacroDataError("The request timeout must be a finite number.") from None
120
+ self._api_key = api_key if api_key is not None else (os.getenv("FXMACRODATA_API_KEY") or os.getenv("FXMD_API_KEY") or "")
121
+ self.timeout = min(max(timeout_value, 1), 120)
122
+ self._session = session or requests.Session()
123
+ self._owns_session = session is None
124
+ if self._owns_session:
125
+ adapter = CredentialSafeAdapter()
126
+ self._session.mount(API_ORIGIN + "/", adapter)
127
+ self._session.mount("https://mcp.fxmacrodata.com/", adapter)
128
+ self._operations = {operation.name: operation for operation in list_operations()}
129
+ self._mcp_headers: dict[str, str] | None = None
130
+ self._request_id = 0
131
+ # requests.Session and MCP initialization both carry mutable state.
132
+ # Serialize a shared client's calls; independent clients can run in parallel.
133
+ self._lock = threading.RLock()
134
+
135
+ def __repr__(self) -> str:
136
+ return f"FXMacroDataClient(authenticated={bool(self._api_key)}, timeout={self.timeout})"
137
+
138
+ def __enter__(self) -> FXMacroDataClient:
139
+ return self
140
+
141
+ def __exit__(self, *_: Any) -> None:
142
+ self.close()
143
+
144
+ def close(self) -> None:
145
+ with self._lock, protected_diagnostics(self._api_key):
146
+ headers, self._mcp_headers = self._mcp_headers, None
147
+ if headers and headers.get("Mcp-Session-Id"):
148
+ response = None
149
+ try:
150
+ response = self._request("DELETE", MCP_URL, params=self._auth_params(),
151
+ headers=headers, timeout=min(self.timeout, 2))
152
+ except Exception:
153
+ # Closing a session is best effort and must not mask a tool
154
+ # result or expose an underlying request/credential error.
155
+ pass
156
+ finally:
157
+ if response is not None:
158
+ response.close()
159
+ if self._owns_session:
160
+ self._session.close()
161
+
162
+ def list_operations(self, include_mcp: bool = True) -> tuple[Operation, ...]:
163
+ with self._lock:
164
+ return tuple(deepcopy(op) for op in self._operations.values() if include_mcp or op.method != "MCP")
165
+
166
+ def _safe(self, value: Any) -> Any:
167
+ if isinstance(value, str):
168
+ return redact_text(value, self._api_key)
169
+ if isinstance(value, list):
170
+ return [self._safe(x) for x in value]
171
+ if isinstance(value, dict):
172
+ secret_fields = {"apikey", "accesstoken", "authorization", "proxyauthorization", "password", "clientsecret", "secret", "token"}
173
+ return {self._safe(k): "[redacted]" if re.sub(r"[_-]", "", k.lower()) in secret_fields else self._safe(v) for k, v in value.items()}
174
+ return value
175
+
176
+ @staticmethod
177
+ def _status(response: requests.Response) -> None:
178
+ if 200 <= response.status_code < 300:
179
+ return
180
+ if response.status_code in {401, 403}:
181
+ detail = "This dataset requires an authorized FXMacroData API key. Public USD catalogue, history and calendar remain available."
182
+ elif response.status_code == 429:
183
+ detail = "FXMacroData rate limit reached. Retry later."
184
+ elif response.status_code == 404:
185
+ detail = "The requested FXMacroData resource is unavailable. Check catalogue discovery."
186
+ else:
187
+ detail = f"FXMacroData request failed (HTTP {response.status_code})."
188
+ raise FXMacroDataError(detail)
189
+
190
+ def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None, timeout: float | None = None) -> requests.Response:
191
+ headers = dict(headers or {})
192
+ if self._api_key:
193
+ headers["X-API-Key"] = self._api_key
194
+ try:
195
+ with protected_diagnostics(self._api_key):
196
+ return self._session.request(method, url, params=params, headers=headers, json=body, timeout=timeout or self.timeout, allow_redirects=False, stream=True)
197
+ except (requests.RequestException, ValueError):
198
+ raise FXMacroDataError("Unable to reach FXMacroData within the request timeout. Retry later.") from None
199
+
200
+ def _bounded_lines(self, response: requests.Response, deadline: float, *, stop_at_deadline: bool = False) -> Iterator[bytes]:
201
+ seen_bytes = 0
202
+ pending = bytearray()
203
+ skip_lf = False
204
+ # iter_lines only yields after a delimiter, so a long undelimited frame
205
+ # could evade both byte and elapsed-time checks. Bound raw bytes first.
206
+ for chunk in response.iter_content(chunk_size=1):
207
+ if not isinstance(chunk, bytes):
208
+ raise FXMacroDataError("FXMacroData returned an invalid event stream.")
209
+ seen_bytes += len(chunk)
210
+ if seen_bytes > MAX_RESPONSE_BYTES:
211
+ raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
212
+ if time.monotonic() > deadline:
213
+ if stop_at_deadline:
214
+ return
215
+ raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
216
+ for value in chunk:
217
+ if skip_lf and value == 10:
218
+ skip_lf = False
219
+ continue
220
+ skip_lf = value == 13
221
+ if value in (10, 13):
222
+ yield bytes(pending)
223
+ pending.clear()
224
+ else:
225
+ pending.append(value)
226
+
227
+ @staticmethod
228
+ @contextmanager
229
+ def _response_deadline(response: requests.Response, deadline: float):
230
+ """Interrupt this response's blocked read when its capture budget ends."""
231
+ expired = threading.Event()
232
+
233
+ def expire() -> None:
234
+ expired.set()
235
+ # Closing a buffered reader alone can wait on its active read lock.
236
+ # Shut down only this response's socket first, which wakes that read.
237
+ raw = getattr(response, "raw", None)
238
+ connection = getattr(raw, "connection", None)
239
+ sock = getattr(connection, "sock", None)
240
+ if sock is None:
241
+ fp = getattr(getattr(raw, "_fp", None), "fp", None)
242
+ sock = getattr(getattr(fp, "raw", None), "_sock", None)
243
+ if sock is not None:
244
+ try:
245
+ sock.shutdown(socket.SHUT_RDWR)
246
+ except (OSError, ValueError):
247
+ pass
248
+ try:
249
+ response.close()
250
+ except Exception:
251
+ pass
252
+
253
+ timer = threading.Timer(max(0, deadline - time.monotonic()), expire)
254
+ timer.daemon = True
255
+ timer.start()
256
+ try:
257
+ yield expired
258
+ finally:
259
+ timer.cancel()
260
+
261
+ def _read_json(self, response: requests.Response, *, response_id: int | None = None) -> Any:
262
+ try:
263
+ self._status(response)
264
+ deadline = time.monotonic() + self.timeout
265
+ if "text/event-stream" in response.headers.get("Content-Type", "").lower():
266
+ with self._response_deadline(response, deadline):
267
+ for event in self._parse_sse(self._bounded_lines(response, deadline)):
268
+ data = event["data"]
269
+ if (isinstance(data, dict) and data.get("jsonrpc") == "2.0"
270
+ and data.get("id") == response_id and ("result" in data or "error" in data)):
271
+ # Streamable HTTP may remain open after delivering this
272
+ # response. Do not wait for EOF or a later keepalive.
273
+ return data
274
+ raise FXMacroDataError("FXMacroData returned no matching MCP response.")
275
+ chunks = bytearray()
276
+ with self._response_deadline(response, deadline):
277
+ for chunk in response.iter_content(chunk_size=65536):
278
+ chunks.extend(chunk)
279
+ if len(chunks) > MAX_RESPONSE_BYTES or time.monotonic() > deadline:
280
+ raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
281
+ raw = chunks.decode("utf-8")
282
+ return json.loads(raw)
283
+ except (requests.RequestException, ValueError, UnicodeError, OSError, AttributeError):
284
+ raise FXMacroDataError("FXMacroData returned an unavailable or invalid response.") from None
285
+ finally:
286
+ response.close()
287
+
288
+ def _auth_params(self) -> dict[str, str]:
289
+ return {} # Credentials are sent only in headers, including MCP sessions.
290
+
291
+ def execute(self, operation_name: str, arguments: dict[str, Any] | None = None) -> Result:
292
+ with self._lock, protected_diagnostics(self._api_key):
293
+ return self._execute(operation_name, arguments)
294
+
295
+ def history(self, operation_name: str, arguments: dict[str, Any] | None = None, *, max_pages=10000, resume=None) -> Result:
296
+ """Download every page of a paginated REST operation, preserving metadata."""
297
+ operation = self._operations.get(operation_name)
298
+ if operation is None or operation.method != "GET" or not any(p["name"] == "offset" for p in operation.parameters):
299
+ raise FXMacroDataError("Complete history requires an offset-paginated REST operation.")
300
+ payload = collect_history(lambda args: self.execute(operation_name, args).payload,
301
+ arguments, max_pages=max_pages, resume=resume, request_id=operation_name,
302
+ require_pagination=True)
303
+ return Result(operation_name, payload)
304
+
305
+ def history_path(self, path: str, params=None, *, max_pages=10000, resume=None) -> dict:
306
+ """Complete read from an FXMacroData REST path, retaining all page metadata."""
307
+ if not isinstance(path, str) or not re.fullmatch(r"/v1/[A-Za-z0-9_/-]+", path) or ".." in path:
308
+ raise FXMacroDataError("Use an FXMacroData /v1/ REST path.")
309
+ query = dict(params or {})
310
+ if any(k.lower() in {"api_key", "access_token", "authorization"} for k in query):
311
+ raise FXMacroDataError("Pass authentication through the client constructor.")
312
+ def fetch(page):
313
+ headers = {"Accept": "application/json"}
314
+ if self._api_key:
315
+ headers["X-API-Key"] = self._api_key
316
+ with self._lock, protected_diagnostics(self._api_key):
317
+ for attempt in range(3):
318
+ try:
319
+ response = self._request("GET", API_ORIGIN + path, params=page, headers=headers)
320
+ except FXMacroDataError:
321
+ if attempt == 2:
322
+ raise
323
+ time.sleep(2 ** attempt)
324
+ continue
325
+ if response.status_code in {429, 500, 502, 503, 504} and attempt < 2:
326
+ retry = response.headers.get("Retry-After", str(2 ** attempt))
327
+ try:
328
+ delay = max(0, float(retry))
329
+ except ValueError:
330
+ from datetime import datetime, timezone
331
+ from email.utils import parsedate_to_datetime
332
+ try:
333
+ delay = max(0, (parsedate_to_datetime(retry) - datetime.now(timezone.utc)).total_seconds())
334
+ except (ValueError, TypeError):
335
+ delay = 2 ** attempt
336
+ if delay <= 30:
337
+ response.close()
338
+ time.sleep(delay)
339
+ continue
340
+ return self._safe(self._read_json(response))
341
+ return collect_history(fetch, query, max_pages=max_pages, resume=resume, request_id=path,
342
+ require_pagination=paginated_history_path(path))
343
+
344
+ def _execute(self, operation_name: str, arguments: dict[str, Any] | None) -> Result:
345
+ if not isinstance(operation_name, str):
346
+ raise FXMacroDataError("Unknown FXMacroData operation. Use operation discovery.")
347
+ op = self._operations.get(operation_name)
348
+ if op is None:
349
+ raise FXMacroDataError("Unknown FXMacroData operation. Use operation discovery.")
350
+ if arguments is not None and (not isinstance(arguments, dict) or any(not isinstance(key, str) for key in arguments)):
351
+ raise FXMacroDataError("FXMacroData arguments must be an object with string keys.")
352
+ args = dict(arguments or {})
353
+ if any(k.lower() in {"api_key", "access_token", "authorization", "url", "base_url"} for k in args):
354
+ raise FXMacroDataError("Credentials and endpoint overrides are not operation arguments.")
355
+ try:
356
+ jsonschema.Draft202012Validator(op.input_schema, registry=SCHEMA_REGISTRY).validate(args)
357
+ except (jsonschema.ValidationError, jsonschema.SchemaError, Unresolvable):
358
+ raise FXMacroDataError("Invalid FXMacroData arguments. Check the operation input schema.") from None
359
+ if op.method == "MCP":
360
+ return self._call_mcp(op.name[4:], args)
361
+ path = op.path
362
+ params: dict[str, Any] = self._auth_params()
363
+ headers = {"Accept": "application/json"}
364
+ body: dict[str, Any] | None = None
365
+ for parameter in op.parameters:
366
+ name = parameter["name"]
367
+ if name not in args or args[name] is None:
368
+ continue
369
+ value = args[name]
370
+ if parameter["in"] == "path":
371
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]+", value) or value in {".", ".."}:
372
+ raise FXMacroDataError("Invalid FXMacroData path parameter.")
373
+ path = path.replace("{" + name + "}", quote(value, safe=""))
374
+ elif parameter["in"] == "header":
375
+ if "\r" in str(value) or "\n" in str(value):
376
+ raise FXMacroDataError("Invalid event cursor.")
377
+ headers[name] = str(value)
378
+ elif parameter["in"] == "body":
379
+ body = value
380
+ else:
381
+ params[name] = str(value).lower() if isinstance(value, bool) else value
382
+ if operation_name == "stream_events":
383
+ payload = self._stream(path, params, headers, args)
384
+ else:
385
+ payload = self._read_json(self._request(op.method, API_ORIGIN + path, params=params, headers=headers, body=body))
386
+ return Result(op.name, self._safe(payload), API_ORIGIN + path)
387
+
388
+ @staticmethod
389
+ def _parse_sse(lines: Any) -> Iterator[dict[str, Any]]:
390
+ fields: dict[str, Any] = {}
391
+ data: list[str] = []
392
+ for raw in lines:
393
+ line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
394
+ if line == "":
395
+ if data:
396
+ text = "\n".join(data)
397
+ try:
398
+ fields["data"] = json.loads(text)
399
+ except ValueError:
400
+ fields["data"] = text
401
+ yield fields
402
+ fields, data = {}, []
403
+ elif not line.startswith(":"):
404
+ key, _, value = line.partition(":")
405
+ value = value.removeprefix(" ")
406
+ if key == "data":
407
+ data.append(value)
408
+ elif key in {"id", "event", "retry"}:
409
+ fields[key] = value
410
+ # A connection ending before the blank event delimiter is incomplete;
411
+ # do not turn a partial stream frame into a completed release event.
412
+
413
+ def _stream(self, path: str, params: dict[str, Any], headers: dict[str, str], args: dict[str, Any]) -> dict[str, Any]:
414
+ seconds = min(float(args.get("max_seconds", 10)), self.timeout)
415
+ count = int(args.get("max_events", 10))
416
+ deadline = time.monotonic() + seconds
417
+ response = self._request("GET", API_ORIGIN + path, params=params, headers={**headers, "Accept": "text/event-stream"}, timeout=seconds)
418
+ events = []
419
+ expired = threading.Event()
420
+ try:
421
+ self._status(response)
422
+ with self._response_deadline(response, deadline) as expired:
423
+ for event in self._parse_sse(self._bounded_lines(response, deadline, stop_at_deadline=True)):
424
+ events.append(event)
425
+ if len(events) >= count:
426
+ break
427
+ except (requests.RequestException, OSError):
428
+ # A quiet stream can reach its finite read deadline without an event.
429
+ return {"events": events, "capture_complete": False, "reason": "timeout"}
430
+ except (UnicodeError, ValueError, TypeError, AttributeError):
431
+ if expired.is_set():
432
+ return {"events": events, "capture_complete": False, "reason": "timeout"}
433
+ raise FXMacroDataError("FXMacroData returned an invalid event stream.") from None
434
+ finally:
435
+ response.close()
436
+ return {"events": events, "capture_complete": len(events) >= count, "reason": "event_limit" if len(events) >= count else "timeout" if expired.is_set() else "stream_end_or_deadline"}
437
+
438
+ def _rpc(self, method: str, params: dict[str, Any], *, headers: dict[str, str], notification: bool = False) -> tuple[Any, dict[str, str]]:
439
+ body: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params}
440
+ if not notification:
441
+ self._request_id += 1
442
+ body["id"] = self._request_id
443
+ response = self._request("POST", MCP_URL, params=self._auth_params(), headers=headers, body=body)
444
+ response_headers = dict(response.headers)
445
+ if notification:
446
+ try:
447
+ self._status(response)
448
+ finally:
449
+ response.close()
450
+ return None, response_headers
451
+ value = self._read_json(response, response_id=body["id"])
452
+ if (not isinstance(value, dict) or value.get("jsonrpc") != "2.0"
453
+ or type(value.get("id")) is not int or value.get("id") != body["id"]
454
+ or "error" in value or "result" not in value):
455
+ raise FXMacroDataError("FXMacroData MCP could not complete this request.")
456
+ return value["result"], response_headers
457
+
458
+ def _initialize_mcp(self) -> dict[str, str]:
459
+ if self._mcp_headers is None:
460
+ headers = {"Accept": "application/json, text/event-stream"}
461
+ result, returned = self._rpc("initialize", {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "fxmacrodata-public-client", "version": "0.1.0"}}, headers=headers)
462
+ if not isinstance(result, dict) or result.get("protocolVersion") != MCP_PROTOCOL_VERSION:
463
+ raise FXMacroDataError("FXMacroData MCP returned an unsupported initialization response.")
464
+ headers["MCP-Protocol-Version"] = MCP_PROTOCOL_VERSION
465
+ for key, value in returned.items():
466
+ if key.lower() == "mcp-session-id":
467
+ if not isinstance(value, str) or not re.fullmatch(r"[\x21-\x7e]+", value):
468
+ raise FXMacroDataError("FXMacroData MCP returned an invalid session header.")
469
+ headers["Mcp-Session-Id"] = value
470
+ self._rpc("notifications/initialized", {}, headers=headers, notification=True)
471
+ self._mcp_headers = headers
472
+ return self._mcp_headers
473
+
474
+ def discover_mcp_tools(self) -> tuple[Operation, ...]:
475
+ """Refresh every advertised hosted MCP tool, including paginated discovery."""
476
+ with self._lock, protected_diagnostics(self._api_key):
477
+ return self._discover_mcp_tools()
478
+
479
+ def _discover_mcp_tools(self) -> tuple[Operation, ...]:
480
+ headers = self._initialize_mcp()
481
+ cursor = None
482
+ seen: set[str] = set()
483
+ discovered = []
484
+ deadline = time.monotonic() + self.timeout
485
+ while True:
486
+ result, _ = self._rpc("tools/list", {"cursor": cursor} if cursor else {}, headers=headers)
487
+ if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
488
+ raise FXMacroDataError("FXMacroData MCP returned an invalid tool catalogue.")
489
+ for tool in result["tools"]:
490
+ if (not isinstance(tool, dict) or not isinstance(tool.get("name"), str)
491
+ or not isinstance(tool.get("inputSchema"), dict)):
492
+ raise FXMacroDataError("FXMacroData MCP returned an invalid tool descriptor.")
493
+ try:
494
+ jsonschema.Draft202012Validator.check_schema(tool["inputSchema"])
495
+ except jsonschema.SchemaError:
496
+ raise FXMacroDataError("FXMacroData MCP returned an invalid tool schema.") from None
497
+ op = Operation("mcp_" + tool["name"], tool.get("description", tool["name"]), tool["inputSchema"], "/mcp", "MCP")
498
+ discovered.append(op)
499
+ cursor = result.get("nextCursor")
500
+ if not cursor:
501
+ break
502
+ if not isinstance(cursor, str):
503
+ raise FXMacroDataError("FXMacroData MCP returned an invalid page cursor.")
504
+ if cursor in seen:
505
+ raise FXMacroDataError("FXMacroData MCP discovery returned a repeated page cursor.")
506
+ if len(seen) >= 100 or len(discovered) > 10000 or time.monotonic() > deadline:
507
+ raise FXMacroDataError("FXMacroData MCP discovery exceeded its bounded response budget.")
508
+ seen.add(cursor)
509
+ # A failed page never partially mutates the usable operation catalogue.
510
+ self._operations.update({operation.name: operation for operation in discovered})
511
+ return tuple(discovered)
512
+
513
+ def _call_mcp(self, name: str, args: dict[str, Any]) -> Result:
514
+ result, _ = self._rpc("tools/call", {"name": name, "arguments": args}, headers=self._initialize_mcp())
515
+ if not isinstance(result, dict) or result.get("isError"):
516
+ raise FXMacroDataError("FXMacroData MCP tool is unavailable. Check arguments and dataset access.")
517
+ return Result("mcp_" + name, self._safe(result), WEBSITE)