mcp-ivolatility-data 0.1.5__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,91 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from typing import Literal
5
+
6
+ __all__ = ["main"]
7
+
8
+
9
+ def main() -> None:
10
+ """
11
+ Main CLI entry point for the iVolatility MCP core server.
12
+ Accepts --transport CLI argument (falls back to MCP_TRANSPORT env var, then stdio).
13
+
14
+ Heavy dependencies (numpy, etc.) are imported lazily inside this
15
+ function so that ``uv run`` can finish installing packages and Python
16
+ can start before the 30-second MCP connection timeout fires.
17
+ """
18
+ from dotenv import load_dotenv
19
+
20
+ # Load environment variables from .env file if it exists
21
+ load_dotenv()
22
+
23
+ parser = argparse.ArgumentParser(description="iVolatility MCP server")
24
+ parser.add_argument(
25
+ "--transport",
26
+ choices=["stdio", "sse", "streamable-http"],
27
+ default=None,
28
+ help="Transport protocol (default: stdio). Overrides MCP_TRANSPORT env var.",
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ # CLI arg takes precedence over env var; default to stdio
33
+ if args.transport is not None:
34
+ transport: Literal["stdio", "sse", "streamable-http"] = args.transport
35
+ else:
36
+ supported_transports: dict[str, Literal["stdio", "sse", "streamable-http"]] = {
37
+ "stdio": "stdio",
38
+ "sse": "sse",
39
+ "streamable-http": "streamable-http",
40
+ }
41
+ mcp_transport_str = os.environ.get("MCP_TRANSPORT", "stdio")
42
+ transport = supported_transports.get(mcp_transport_str, "stdio")
43
+
44
+ # Check API key and print startup message.
45
+ # Startup messages go to stderr — stdout is the MCP protocol channel
46
+ # for stdio transport; non-JSON data there corrupts the handshake.
47
+ # IVOL_API_KEY is the canonical name; API_KEY is accepted as a fallback for
48
+ # consistency with the other iVolatility deliverables (workspace prompts,
49
+ # ivolai-dist .env). The specific name wins when both are set.
50
+ ivol_api_key = os.environ.get("IVOL_API_KEY", "") or os.environ.get("API_KEY", "")
51
+ if ivol_api_key:
52
+ print(
53
+ "Starting iVolatility MCP server with API key configured.", file=sys.stderr
54
+ )
55
+ else:
56
+ print(
57
+ "Warning: neither IVOL_API_KEY nor API_KEY environment variable is set.",
58
+ file=sys.stderr,
59
+ )
60
+
61
+ base_url = os.environ.get(
62
+ "IVOL_API_BASE_URL", "https://restapi.ivolatility.com"
63
+ ).rstrip("/")
64
+ # Endpoint index sources: OpenAPI REST spec (required) + prompt supplement (optional).
65
+ openapi_path = os.environ.get("IVOL_OPENAPI_PATH")
66
+ supplement_path = os.environ.get("IVOL_SUPPLEMENT_PATH")
67
+ # Instructions dir (the resalka): role + discovery + access rules in .md chunks.
68
+ instructions_dir = os.environ.get("IVOL_INSTRUCTIONS_DIR")
69
+
70
+ max_tables: int | None = None
71
+ max_rows: int | None = None
72
+ if os.environ.get("IVOL_MAX_TABLES"):
73
+ max_tables = int(os.environ["IVOL_MAX_TABLES"])
74
+ if os.environ.get("IVOL_MAX_ROWS"):
75
+ max_rows = int(os.environ["IVOL_MAX_ROWS"])
76
+
77
+ # Defer importing server until after env vars are read — this triggers
78
+ # loading numpy and other heavy deps.
79
+ from .server import run, configure_credentials
80
+
81
+ configure_credentials(
82
+ ivol_api_key,
83
+ base_url,
84
+ openapi_path=openapi_path,
85
+ supplement_path=supplement_path,
86
+ instructions_dir=instructions_dir,
87
+ max_tables=max_tables,
88
+ max_rows=max_rows,
89
+ )
90
+
91
+ run(transport=transport)
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,50 @@
1
+ """Instructions assembler — the connector's "resalka" (slicer), the inverse of
2
+ the workspace split.py.
3
+
4
+ Behaviour (role, endpoint-choosing rules, account/access rule, quirks) lives in
5
+ small editable Markdown chunks under an instructions directory. This stitches
6
+ them, in filename order, into the single server-instructions string the MCP
7
+ client injects into its system context. Each connector (data / pod) ships its
8
+ own instructions directory; the mechanism is shared here in core.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+
14
+ def assemble_instructions(
15
+ instructions_dir: str | None, base: str = "", transport: str | None = None
16
+ ) -> str:
17
+ """Concatenate sorted ``*.md`` files in ``instructions_dir`` into one string.
18
+
19
+ ``base`` (optional) is placed first as a fallback/header. Missing directory
20
+ or no files → returns just ``base``.
21
+
22
+ Transport-specific chunks: guidance that is only true for one deployment
23
+ (e.g. export_data writes a LOCAL file only when the server runs on the
24
+ user's machine) lives in variant files. ``<name>.stdio.md`` is included
25
+ only for the stdio transport (local run, the default when ``transport`` is
26
+ None); ``<name>.remote.md`` only for network transports (sse /
27
+ streamable-http). Plain ``*.md`` chunks are always included.
28
+ """
29
+ mode = "stdio" if transport in (None, "stdio") else "remote"
30
+ parts: list[str] = []
31
+ if base and base.strip():
32
+ parts.append(base.strip())
33
+
34
+ if instructions_dir:
35
+ d = Path(instructions_dir)
36
+ if d.is_dir():
37
+ for f in sorted(d.glob("*.md")):
38
+ # Skip dotfiles / macOS AppleDouble sidecars (._*.md) that tar may
39
+ # carry along — they are binary and break UTF-8 decoding.
40
+ if f.name.startswith("."):
41
+ continue
42
+ if f.name.endswith(".stdio.md") and mode != "stdio":
43
+ continue
44
+ if f.name.endswith(".remote.md") and mode != "remote":
45
+ continue
46
+ text = f.read_text(encoding="utf-8").strip()
47
+ if text:
48
+ parts.append(text)
49
+
50
+ return "\n\n".join(parts)
@@ -0,0 +1,201 @@
1
+ import re
2
+
3
+ # Kept only for import-compatibility with index.py (the Massive markdown
4
+ # builder, unused now that the index is built from OpenAPI + supplement).
5
+ _DEFAULT_LLMS_FULL_TXT_URL = "https://restapi.ivolatility.com/api/openapi"
6
+
7
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
8
+
9
+ # Aliases map abbreviations/synonyms a user might type to canonical terms that
10
+ # appear in iVolatility endpoint names/descriptions/fields. Values can be a
11
+ # single string or a list of strings. Terms must match real index vocabulary.
12
+ ALIASES: dict[str, str | list[str]] = {
13
+ # ── implied volatility / IVX term structure ──
14
+ "iv": ["ivx", "implied"],
15
+ "impliedvol": ["ivx", "implied"],
16
+ "impliedvolatility": ["ivx", "implied"],
17
+ "volatility": ["ivx", "ivs", "hv"],
18
+ "vol": ["ivx", "ivs"],
19
+ "ivx": "ivx",
20
+ "term": ["ivx"],
21
+ "termstructure": ["ivx"],
22
+ "lean": ["ivx", "rawiv"], # IV-lean signal: ivx OR options-rawiv source
23
+ # ── IV surface / skew ──
24
+ "surface": ["ivs"],
25
+ "ivsurface": ["ivs"],
26
+ "skew": ["ivs", "delta"], # surfaced via ivs-by-delta
27
+ "smile": ["ivs"],
28
+ # ── IV rank / percentile ──
29
+ "ivr": ["ivx", "historical", "range"],
30
+ "ivrank": ["ivx", "historical", "range"],
31
+ "ivp": ["ivx", "percentile"],
32
+ "percentile": ["ivx", "ivp"],
33
+ # ── historical / realized vol ──
34
+ "hv": "hv",
35
+ "historicalvol": "hv",
36
+ "realized": "hv",
37
+ "realizedvol": "hv",
38
+ # ── options / chains ──
39
+ "option": "options",
40
+ "contract": "options",
41
+ "contracts": "options",
42
+ "chain": ["options", "rawiv", "param"],
43
+ "optionchain": ["options", "rawiv"],
44
+ "strike": "options",
45
+ "expiry": "options",
46
+ "expiration": "options",
47
+ "dte": "options",
48
+ "moneyness": ["options", "param"],
49
+ "rawiv": "rawiv",
50
+ "1545": ["1545"],
51
+ # ── NBBO / quotes ──
52
+ "nbbo": ["nbbo", "quote"],
53
+ "bid": ["nbbo", "quote"],
54
+ "ask": ["nbbo", "quote"],
55
+ "quote": ["nbbo", "quote"],
56
+ # ── greeks (functions + options) ──
57
+ "greek": ["greeks", "options"],
58
+ "greeks": ["options"],
59
+ "delta": ["bs_delta", "delta", "options"],
60
+ "gamma": ["bs_gamma", "options"],
61
+ "theta": ["bs_theta", "options"],
62
+ "vega": ["bs_vega", "options"],
63
+ "rho": ["bs_rho", "options"],
64
+ "vanna": ["bs_vanna", "options"],
65
+ "volga": ["bs_volga", "options"],
66
+ "vomma": ["bs_volga", "options"],
67
+ "charm": ["bs_charm", "options"],
68
+ "veta": ["bs_veta", "options"],
69
+ "color": ["bs_color", "options"],
70
+ "blackscholes": ["bs_price", "bs_delta"],
71
+ # ── prices / OHLC ──
72
+ "price": ["prices", "stock"],
73
+ "prices": ["prices", "stock"],
74
+ "close": "prices",
75
+ "ohlc": "prices",
76
+ "stock": ["stock", "prices"],
77
+ "stockprice": ["stock", "prices"],
78
+ # ── earnings ──
79
+ "earnings": ["earnings", "calendar"],
80
+ "eps": "earnings",
81
+ "impliedmove": ["earnings"],
82
+ # ── futures ──
83
+ "future": "futures",
84
+ "futures": "futures",
85
+ "fut": "futures",
86
+ # ── interest rates / yield / dividends ──
87
+ "yield": ["yield", "interest"],
88
+ "dividend": ["yield", "dividend"],
89
+ "rate": ["interest", "rates"],
90
+ "rates": ["interest", "rates"],
91
+ # ── fixed income / curves ──
92
+ "bond": ["bond", "fixedincome"],
93
+ "bonds": ["bond", "fixedincome"],
94
+ "fixedincome": ["fixedincome", "bond"],
95
+ "cashflow": ["cashflow"],
96
+ "msrb": ["msrb"],
97
+ "curve": ["curve", "discount", "zero"],
98
+ "curves": ["curve", "discount", "zero"],
99
+ # ── real-time / streaming ──
100
+ "realtime": ["rt", "realtime"],
101
+ "real-time": ["rt", "realtime"],
102
+ "live": ["rt", "realtime"],
103
+ "rt": ["rt", "realtime"],
104
+ "stream": ["websocket", "rtdl"],
105
+ "streaming": ["websocket", "rtdl"],
106
+ "websocket": ["websocket", "rtdl"],
107
+ "ws": ["websocket", "rtdl"],
108
+ # ── intraday ──
109
+ "intraday": ["intraday"],
110
+ "minute": ["intraday"],
111
+ "minutes": ["intraday"],
112
+ "bar": ["intraday"],
113
+ "bars": ["intraday"],
114
+ # ── account / entitlements ──
115
+ "account": ["account", "access"],
116
+ "access": ["account", "access"],
117
+ "tariff": ["account", "access"],
118
+ "tariffs": ["account", "access"],
119
+ "entitlement": ["account", "access"],
120
+ "entitlements": ["account", "access"],
121
+ "quota": ["account", "access"],
122
+ "limit": ["account", "access"],
123
+ "limits": ["account", "access"],
124
+ # ── discovery / reference ──
125
+ "symbol": ["underlying", "ticker"],
126
+ "symbols": ["underlying", "ticker"],
127
+ "ticker": ["underlying", "ticker", "tickers"],
128
+ "lookup": ["underlying", "series"],
129
+ "series": ["series"],
130
+ "calendar": ["calendar", "trading"],
131
+ "split": ["corp", "actions"],
132
+ "dividends": ["corp", "actions"],
133
+ "corporateactions": ["corp", "actions"],
134
+ # ── instrument metadata (underlying-info: stocks + futures) ──
135
+ "constituent": ["underlying"],
136
+ "constituents": ["underlying"],
137
+ "member": ["underlying"],
138
+ "members": ["underlying"],
139
+ "stockgroup": ["underlying"],
140
+ "etf": ["underlying", "ticker"],
141
+ "etfs": ["underlying", "ticker"],
142
+ "multiplier": ["futures", "underlying"],
143
+ "contractsize": ["futures", "underlying"],
144
+ "root": ["futures", "underlying"],
145
+ "roots": ["futures", "underlying"],
146
+ }
147
+
148
+ # Market keywords used to infer the market facet from a free-text query.
149
+ # Values must match the `market` column values (x-tagGroup names) produced by
150
+ # index_openapi.py / the supplement, so an inferred-market boost lands on the
151
+ # right rows. Equities/Options is the default and intentionally has no keywords.
152
+ _MARKET_KEYWORDS: dict[str, set[str]] = {
153
+ "Futures/Options": {
154
+ "future",
155
+ "futures",
156
+ "fut",
157
+ "commodity",
158
+ "commodities",
159
+ },
160
+ "RealTime Options": {
161
+ "realtime",
162
+ "streaming",
163
+ "websocket",
164
+ "rtdl",
165
+ },
166
+ "Fixed Income": {
167
+ "bond",
168
+ "bonds",
169
+ "fixedincome",
170
+ "cashflow",
171
+ "msrb",
172
+ "debt",
173
+ },
174
+ "Curves": {"curve", "curves", "discount", "zero"},
175
+ "Account": {"account", "tariff", "tariffs", "entitlement", "entitlements", "quota"},
176
+ "WebSocket": {"websocket", "wss", "subscribe"},
177
+ }
178
+
179
+ _BULLET_PARAM_RE = re.compile(r"^-\s+\*{0,2}(\w+)\*{0,2}\s*\(([^)]+)\)(?::\s*(.*))?$")
180
+
181
+ _STRUCTURAL_SECTIONS = {"Query Parameters", "Response Attributes", "Sample Response"}
182
+
183
+ # Uppercase 2-5 letter token pattern to detect ticker symbols in the
184
+ # original-case query (fallback signal for the equities market).
185
+ _UPPER_TICKER_RE = re.compile(r"\b([A-Z]{2,5})\b")
186
+
187
+ # Uppercase tokens that look like tickers but are acronyms / non-tickers and
188
+ # must NOT trigger a market-inference fallback.
189
+ _TICKER_FALLBACK_EXCLUSIONS: set[str] = {
190
+ # technical / option jargon
191
+ "IV", "IVX", "IVS", "IVR", "IVP", "HV", "RV", "ATM", "OTM", "ITM",
192
+ "DTE", "OHLC", "OHLCV", "VWAP", "NBBO", "RT", "WS",
193
+ # asset-class / general acronyms
194
+ "FX", "ETF", "ETFS", "IPO", "API", "URL", "JSON", "HTTP", "ID", "AI",
195
+ "EOD", "OSI", "CSV", "ZIP",
196
+ # geo / regulatory / business
197
+ "USA", "UK", "EU", "GMT", "AM", "PM", "OK", "OTC",
198
+ "NYSE", "NASDAQ", "AMEX", "CME", "NYMEX", "CBOT", "EUREX",
199
+ "CEO", "CFO", "CTO", "COO", "FED", "SEC", "FDA", "GDP", "CPI", "IRS",
200
+ "P", "S",
201
+ }
@@ -0,0 +1,189 @@
1
+ import json
2
+ import csv
3
+ import io
4
+ from typing import Any
5
+
6
+
7
+ def strip_response_metadata(json_text: str, exclude_keys: set) -> str:
8
+ """Strip metadata keys from a JSON response string.
9
+
10
+ Parses the JSON, removes top-level keys in exclude_keys, and re-serializes.
11
+ """
12
+ data = json.loads(json_text)
13
+ if isinstance(data, dict):
14
+ for key in exclude_keys:
15
+ data.pop(key, None)
16
+ return json.dumps(data)
17
+
18
+
19
+ def extract_records(data: str | dict | list) -> list[dict]:
20
+ """Extract and flatten records from raw JSON input.
21
+
22
+ Takes raw JSON input (string or parsed), extracts the records list
23
+ (handling 'results', 'last', list, and single-object cases), flattens
24
+ each record via _flatten_dict, and returns a list of flat dicts.
25
+
26
+ Args:
27
+ data: JSON string, dict, or list.
28
+
29
+ Returns:
30
+ List of flattened dictionaries.
31
+ """
32
+ if isinstance(data, str):
33
+ try:
34
+ data = json.loads(data)
35
+ except json.JSONDecodeError:
36
+ return []
37
+
38
+ # Track whether "results" was a proper list — if so, the record count is
39
+ # authoritative and we should not re-interpret a single result's nested
40
+ # lists as the "real" row data.
41
+ results_was_list = False
42
+
43
+ if isinstance(data, dict) and isinstance(data.get("data"), list):
44
+ # iVolatility envelope: {status, query, data: [...]}. The rows live in
45
+ # "data"; treat it like a proper results list.
46
+ records = data["data"]
47
+ results_was_list = True
48
+ elif isinstance(data, dict) and "results" in data:
49
+ results_value = data["results"]
50
+ if isinstance(results_value, list):
51
+ records = results_value
52
+ results_was_list = True
53
+ elif isinstance(results_value, dict):
54
+ records = [results_value]
55
+ else:
56
+ records = [results_value]
57
+ elif isinstance(data, dict) and "last" in data:
58
+ records = [data["last"]] if isinstance(data["last"], dict) else [data]
59
+ elif isinstance(data, list):
60
+ records = data
61
+ results_was_list = True
62
+ else:
63
+ records = [data]
64
+
65
+ # If we got a single record containing list-of-dicts values AND it did
66
+ # NOT come from a proper results list, those inner lists are the real
67
+ # row data. This handles API responses like:
68
+ # {"tickers": [{...}, ...]} (snapshots / gainers)
69
+ # {"underlying": {...}, "values": [...]} (technical indicators via
70
+ # "results" as a dict, not a list)
71
+ # {"dividends": [...], "splits": [...]} (hypothetical multi-list
72
+ # responses — rows from every list are emitted and tagged with
73
+ # a ``_source`` column so downstream can disambiguate)
74
+ if len(records) == 1 and isinstance(records[0], dict) and not results_was_list:
75
+ record = records[0]
76
+ list_of_dict_keys = [
77
+ k
78
+ for k, v in record.items()
79
+ if isinstance(v, list) and v and isinstance(v[0], dict)
80
+ ]
81
+ if list_of_dict_keys:
82
+ chosen = set(list_of_dict_keys)
83
+ # Carry every sibling value into the child rows. Sibling lists
84
+ # (of any kind) are stringified rather than silently dropped —
85
+ # this matches _flatten_dict's treatment of list values.
86
+ parent_fields: dict[str, Any] = {}
87
+ for pk, pv in record.items():
88
+ if pk in chosen:
89
+ continue
90
+ if isinstance(pv, dict):
91
+ for fk, fv in _flatten_dict(pv, pk).items():
92
+ if not isinstance(fv, (dict, list)):
93
+ parent_fields[fk] = fv
94
+ elif isinstance(pv, list):
95
+ parent_fields[pk] = str(pv)
96
+ else:
97
+ parent_fields[pk] = pv
98
+ # Emit rows from every list-of-dicts key. When multiple such
99
+ # keys exist, tag each row with its source so overlapping
100
+ # column names (e.g. "date" in both dividends and splits) do
101
+ # not collide ambiguously downstream.
102
+ tag_source = len(list_of_dict_keys) > 1
103
+ new_records: list = []
104
+ for key in list_of_dict_keys:
105
+ for item in record[key]:
106
+ if isinstance(item, dict):
107
+ row = {**parent_fields, **item}
108
+ if tag_source:
109
+ row["_source"] = key
110
+ new_records.append(row)
111
+ else:
112
+ # Preserve non-dict list entries; the final
113
+ # flattening step wraps them as {"value": ...}.
114
+ new_records.append(item)
115
+ records = new_records
116
+
117
+ flattened_records = []
118
+ for record in records:
119
+ if isinstance(record, dict):
120
+ flattened_records.append(_flatten_dict(record))
121
+ else:
122
+ flattened_records.append({"value": str(record)})
123
+
124
+ return flattened_records
125
+
126
+
127
+ def json_to_csv(json_input: str | dict | list) -> str:
128
+ """
129
+ Convert JSON to flattened CSV format.
130
+
131
+ Args:
132
+ json_input: JSON string or dict. If the JSON has a 'results' key containing
133
+ a list, it will be extracted. Otherwise, the entire structure
134
+ will be wrapped in a list for processing.
135
+
136
+ Returns:
137
+ CSV string with headers and flattened rows
138
+ """
139
+ flattened_records = extract_records(json_input)
140
+
141
+ if not flattened_records:
142
+ return ""
143
+
144
+ # Get all unique keys across all records (for consistent column ordering)
145
+ all_keys = []
146
+ seen = set()
147
+ for record in flattened_records:
148
+ if isinstance(record, dict):
149
+ for key in record.keys():
150
+ if key not in seen:
151
+ all_keys.append(key)
152
+ seen.add(key)
153
+
154
+ output = io.StringIO()
155
+ writer = csv.DictWriter(output, fieldnames=all_keys, lineterminator="\n")
156
+ writer.writeheader()
157
+ writer.writerows(flattened_records)
158
+
159
+ return output.getvalue()
160
+
161
+
162
+ def _flatten_dict(
163
+ d: dict[str, Any], parent_key: str = "", sep: str = "_"
164
+ ) -> dict[str, Any]:
165
+ """
166
+ Flatten a nested dictionary by joining keys with separator.
167
+
168
+ Args:
169
+ d: Dictionary to flatten
170
+ parent_key: Key from parent level (for recursion)
171
+ sep: Separator to use between nested keys
172
+
173
+ Returns:
174
+ Flattened dictionary with no nested structures
175
+ """
176
+ items = []
177
+ for k, v in d.items():
178
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
179
+
180
+ if isinstance(v, dict):
181
+ # Recursively flatten nested dicts
182
+ items.extend(_flatten_dict(v, new_key, sep=sep).items())
183
+ elif isinstance(v, list):
184
+ # Convert lists to comma-separated strings
185
+ items.append((new_key, str(v)))
186
+ else:
187
+ items.append((new_key, v))
188
+
189
+ return dict(items)