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.
- fxmacrodata_public/__init__.py +8 -0
- fxmacrodata_public/client.py +517 -0
- fxmacrodata_public/mcp-tools.json +4052 -0
- fxmacrodata_public/operations.json +5083 -0
- fxmacrodata_public/pagination.py +134 -0
- fxmacrodata_public/py.typed +0 -0
- fxmacrodata_public/research.py +209 -0
- fxmacrodata_public/transport.py +80 -0
- fxmacrodata_public_client-0.1.1.dist-info/METADATA +56 -0
- fxmacrodata_public_client-0.1.1.dist-info/RECORD +13 -0
- fxmacrodata_public_client-0.1.1.dist-info/WHEEL +5 -0
- fxmacrodata_public_client-0.1.1.dist-info/licenses/LICENSE +21 -0
- fxmacrodata_public_client-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|