fxmacrodata-public-client 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.
@@ -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)