defistream-mcp 0.2.0__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,3 @@
1
+ """DeFiStream MCP Server — Model Context Protocol server for the DeFiStream API."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,102 @@
1
+ """Async HTTP client wrapper for the DeFiStream API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .config import ServerConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _client: DeFiStreamAPIClient | None = None
16
+
17
+
18
+ def get_client() -> DeFiStreamAPIClient:
19
+ """Return the module-level API client. Must call ``init_client`` first."""
20
+ if _client is None:
21
+ raise RuntimeError("API client not initialised — call init_client() first")
22
+ return _client
23
+
24
+
25
+ def init_client(config: ServerConfig) -> DeFiStreamAPIClient:
26
+ """Create and store the module-level API client."""
27
+ global _client
28
+ _client = DeFiStreamAPIClient(config)
29
+ return _client
30
+
31
+
32
+ def create_client_with_key(api_key: str) -> DeFiStreamAPIClient:
33
+ """Create a temporary client with a user-provided API key.
34
+
35
+ Uses the base_url and other settings from the global client's config.
36
+ This allows users to authenticate requests with their own API keys.
37
+ """
38
+ if _client is None:
39
+ raise RuntimeError("API client not initialised — call init_client() first")
40
+
41
+ temp_config = ServerConfig(
42
+ api_key=api_key,
43
+ base_url=_client.config.base_url,
44
+ transport=_client.config.transport,
45
+ host=_client.config.host,
46
+ port=_client.config.port,
47
+ download_dir=_client.config.download_dir,
48
+ query_row_limit=_client.config.query_row_limit,
49
+ local=_client.config.local,
50
+ )
51
+ return DeFiStreamAPIClient(temp_config)
52
+
53
+
54
+ class DeFiStreamAPIClient:
55
+ """Lightweight async wrapper around the DeFiStream REST API."""
56
+
57
+ def __init__(self, config: ServerConfig) -> None:
58
+ self.config = config
59
+ self._http = httpx.AsyncClient(
60
+ base_url=config.base_url,
61
+ headers={"X-API-Key": config.api_key},
62
+ timeout=httpx.Timeout(60.0, connect=10.0),
63
+ )
64
+
65
+ async def get_json(
66
+ self,
67
+ path: str,
68
+ params: dict[str, Any] | None = None,
69
+ ) -> tuple[Any, dict[str, str]]:
70
+ """GET *path* and return ``(parsed_json, response_headers)``."""
71
+ resp = await self._http.get(path, params=params)
72
+ resp.raise_for_status()
73
+ headers = {
74
+ k: v
75
+ for k, v in resp.headers.items()
76
+ if k.lower().startswith("x-ratelimit") or k.lower() == "x-request-cost"
77
+ }
78
+ return resp.json(), headers
79
+
80
+ async def download_to_file(
81
+ self,
82
+ path: str,
83
+ params: dict[str, Any],
84
+ output_path: Path,
85
+ ) -> int:
86
+ """Stream a GET response to *output_path*. Returns bytes written."""
87
+ total = 0
88
+ async with self._http.stream("GET", path, params=params) as resp:
89
+ resp.raise_for_status()
90
+ with open(output_path, "wb") as f:
91
+ async for chunk in resp.aiter_bytes(chunk_size=65_536):
92
+ f.write(chunk)
93
+ total += len(chunk)
94
+ return total
95
+
96
+ def build_url(self, path: str, params: dict[str, Any]) -> str:
97
+ """Build the full URL for *path* with query params (for SSE download links)."""
98
+ req = self._http.build_request("GET", path, params=params)
99
+ return str(req.url)
100
+
101
+ async def close(self) -> None:
102
+ await self._http.aclose()
@@ -0,0 +1,99 @@
1
+ """Environment-based configuration for the DeFiStream MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ _PRODUCTION_URL = "https://api.defistream.dev/v1"
12
+ _LOCAL_URL = "http://localhost:8081/v1"
13
+
14
+
15
+ def _load_dotenv() -> None:
16
+ """Load .env from mcp-server/ dir and monorepo root. Existing env vars take precedence."""
17
+ pkg_dir = Path(__file__).resolve().parents[2] # mcp-server/
18
+ load_dotenv(pkg_dir / ".env", override=False)
19
+ # Also try monorepo root (one level up from mcp-server/)
20
+ load_dotenv(pkg_dir.parent / ".env", override=False)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServerConfig:
25
+ """Server configuration loaded from environment variables."""
26
+
27
+ api_key: str = "" # Optional - only for testing/utility tools; users provide their own
28
+ base_url: str = _PRODUCTION_URL
29
+ transport: str = "stdio"
30
+ host: str = "0.0.0.0" # Bind address for SSE transport
31
+ port: int = 8000 # Port for SSE transport
32
+ download_dir: str = "."
33
+ query_row_limit: int = 10000
34
+ local: bool = False
35
+
36
+ @classmethod
37
+ def from_env(cls) -> ServerConfig:
38
+ """Build config from environment variables.
39
+
40
+ Env vars:
41
+ DEFISTREAM_API_KEY (optional) — API key for utility tools; users provide their own for queries
42
+ DEFISTREAM_LOCAL (optional) — "true" to use local gateway
43
+ DEFISTREAM_BASE_URL (optional) — API base URL (overrides --local)
44
+ DEFISTREAM_MCP_TRANSPORT(optional) — "stdio" or "sse"
45
+ DEFISTREAM_MCP_HOST (optional) — Bind address for SSE (default: 0.0.0.0)
46
+ DEFISTREAM_MCP_PORT (optional) — Port for SSE (default: 8000)
47
+ DEFISTREAM_DOWNLOAD_DIR (optional) — default download directory
48
+ DEFISTREAM_QUERY_ROW_LIMIT (optional) — max rows for query_events
49
+ """
50
+ _load_dotenv()
51
+
52
+ # API key is now optional - users provide their own per-request
53
+ api_key = os.environ.get("DEFISTREAM_API_KEY", "")
54
+
55
+ local = os.environ.get("DEFISTREAM_LOCAL", "").lower() in ("1", "true", "yes")
56
+
57
+ # Explicit DEFISTREAM_BASE_URL takes precedence, then local flag, then production
58
+ explicit_url = os.environ.get("DEFISTREAM_BASE_URL")
59
+ if explicit_url:
60
+ base_url = explicit_url.rstrip("/")
61
+ elif local:
62
+ base_url = _LOCAL_URL
63
+ else:
64
+ base_url = _PRODUCTION_URL
65
+
66
+ transport = os.environ.get("DEFISTREAM_MCP_TRANSPORT", "stdio").lower()
67
+ if transport not in ("stdio", "sse", "http"):
68
+ raise ValueError(
69
+ f"DEFISTREAM_MCP_TRANSPORT must be 'stdio', 'sse', or 'http', got '{transport}'"
70
+ )
71
+
72
+ host = os.environ.get("DEFISTREAM_MCP_HOST", "0.0.0.0")
73
+
74
+ port_str = os.environ.get("DEFISTREAM_MCP_PORT", "8000")
75
+ try:
76
+ port = int(port_str)
77
+ except ValueError:
78
+ raise ValueError(
79
+ f"DEFISTREAM_MCP_PORT must be an integer, got '{port_str}'"
80
+ )
81
+
82
+ row_limit_str = os.environ.get("DEFISTREAM_QUERY_ROW_LIMIT", "10000")
83
+ try:
84
+ row_limit = int(row_limit_str)
85
+ except ValueError:
86
+ raise ValueError(
87
+ f"DEFISTREAM_QUERY_ROW_LIMIT must be an integer, got '{row_limit_str}'"
88
+ )
89
+
90
+ return cls(
91
+ api_key=api_key,
92
+ base_url=base_url,
93
+ transport=transport,
94
+ host=host,
95
+ port=port,
96
+ download_dir=os.environ.get("DEFISTREAM_DOWNLOAD_DIR", "."),
97
+ query_row_limit=row_limit,
98
+ local=local,
99
+ )
@@ -0,0 +1,247 @@
1
+ """Pure formatting helpers for MCP tool responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def format_events_response(
10
+ body: dict[str, Any],
11
+ headers: dict[str, str],
12
+ limit: int,
13
+ ) -> str:
14
+ """Format a JSON events response, truncating to *limit* rows."""
15
+ events = body.get("events", [])
16
+ total = body.get("count", len(events))
17
+
18
+ truncated = events[:limit]
19
+ parts: list[str] = []
20
+
21
+ if total > limit:
22
+ parts.append(f"Showing {limit} of {total} events (truncated).\n")
23
+ else:
24
+ parts.append(f"{total} event(s) returned.\n")
25
+
26
+ parts.append(json.dumps(truncated, indent=2))
27
+ parts.append(_format_quota(headers))
28
+ return "\n".join(parts)
29
+
30
+
31
+ def format_aggregate_response(
32
+ body: dict[str, Any],
33
+ headers: dict[str, str],
34
+ ) -> str:
35
+ """Format an aggregate query response."""
36
+ data = body.get("data", [])
37
+ parts: list[str] = [
38
+ f"{body.get('count', len(data))} bucket(s) returned.",
39
+ f"group_by: {body.get('group_by', '?')} period: {body.get('period', '?')}",
40
+ ]
41
+
42
+ cols = body.get("columns_aggregated", [])
43
+ if cols:
44
+ col_names = [c.get("output_column", "?") for c in cols]
45
+ parts.append(f"Aggregated columns: {', '.join(col_names)}")
46
+
47
+ parts.append("")
48
+ parts.append(json.dumps(data, indent=2))
49
+ parts.append(_format_quota(headers))
50
+ return "\n".join(parts)
51
+
52
+
53
+ def format_download_summary(
54
+ file_path: str,
55
+ size_bytes: int,
56
+ ) -> str:
57
+ """Summarise a completed file download."""
58
+ if size_bytes < 1024:
59
+ size_str = f"{size_bytes} B"
60
+ elif size_bytes < 1024 * 1024:
61
+ size_str = f"{size_bytes / 1024:.1f} KB"
62
+ else:
63
+ size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
64
+ return f"Downloaded to {file_path} ({size_str})"
65
+
66
+
67
+ def build_download_url(
68
+ base_url: str,
69
+ protocol: str,
70
+ event_type: str,
71
+ params: dict[str, Any],
72
+ ) -> str:
73
+ """Build a full download URL for SSE transport."""
74
+ from urllib.parse import urlencode
75
+
76
+ qs = urlencode({k: v for k, v in params.items() if v is not None})
77
+ path = f"{base_url}/{protocol}/events/{event_type}"
78
+ return f"{path}?{qs}" if qs else path
79
+
80
+
81
+ def format_link_response(
82
+ body: dict[str, Any],
83
+ headers: dict[str, str],
84
+ ) -> str:
85
+ """Format a link response from the API."""
86
+ filename = body.get("filename", "unknown")
87
+ link = body.get("link", "")
88
+ expiry = body.get("expiry", "unknown")
89
+ size = body.get("size")
90
+
91
+ parts: list[str] = [
92
+ "Download link generated:",
93
+ f" Filename: {filename}",
94
+ f" Link: {link}",
95
+ f" Expires: {expiry}",
96
+ ]
97
+
98
+ if size is not None:
99
+ parts.append(f" Size: {size}")
100
+
101
+ parts.append("")
102
+ parts.append("Use directly with polars/pandas:")
103
+ parts.append(f' pl.read_{"parquet" if filename.endswith(".parquet") else "csv"}("{link}")')
104
+ parts.append(_format_quota(headers))
105
+
106
+ return "\n".join(parts)
107
+
108
+
109
+ def _format_quota(headers: dict[str, str]) -> str:
110
+ """Format rate-limit / quota headers into a short summary line."""
111
+ remaining = headers.get("x-ratelimit-remaining")
112
+ cost = headers.get("x-request-cost")
113
+ if not remaining and not cost:
114
+ return ""
115
+ parts = []
116
+ if cost:
117
+ parts.append(f"cost={cost} CU")
118
+ if remaining:
119
+ parts.append(f"remaining={remaining} CU")
120
+ return "\n[Quota: " + ", ".join(parts) + "]"
121
+
122
+
123
+ def format_local_execution_guide(query: str, base_url: str) -> str:
124
+ """Format a guide for executing a query locally using various methods."""
125
+ from urllib.parse import parse_qs, urlencode
126
+
127
+ # Parse the query to extract components for the Python client example
128
+ if "?" in query:
129
+ path, qs = query.split("?", 1)
130
+ params = parse_qs(qs, keep_blank_values=True)
131
+ # Flatten single-value lists
132
+ params_flat = {k: v[0] if len(v) == 1 else v for k, v in params.items()}
133
+ else:
134
+ path = query
135
+ params_flat = {}
136
+
137
+ # Build full URL
138
+ full_url = f"{base_url}{query}"
139
+
140
+ # Build URL with CSV format
141
+ csv_params = dict(params_flat)
142
+ csv_params["format"] = "csv"
143
+ csv_qs = urlencode(csv_params)
144
+ csv_url = f"{base_url}{path}?{csv_qs}"
145
+
146
+ # Build URL with JSON format
147
+ json_params = dict(params_flat)
148
+ json_params["format"] = "json"
149
+ json_qs = urlencode(json_params)
150
+ json_url = f"{base_url}{path}?{json_qs}"
151
+
152
+ # Build URL with parquet + link
153
+ parquet_params = dict(params_flat)
154
+ parquet_params["format"] = "parquet"
155
+ parquet_params["link"] = "true"
156
+ parquet_qs = urlencode(parquet_params)
157
+ parquet_url = f"{base_url}{path}?{parquet_qs}"
158
+
159
+ # Extract protocol info for Python client example
160
+ path_parts = path.strip("/").split("/")
161
+ protocol = path_parts[0] if path_parts else "erc20"
162
+ event_type = path_parts[2] if len(path_parts) > 2 else "transfer"
163
+
164
+ # Build Python client method call
165
+ client_params = []
166
+ for k, v in params_flat.items():
167
+ if k in ("format", "link", "verbose"):
168
+ continue
169
+ if isinstance(v, str):
170
+ client_params.append(f'{k}="{v}"')
171
+ else:
172
+ client_params.append(f"{k}={v}")
173
+ client_args = ", ".join(client_params)
174
+
175
+ guide = f"""## Query Local Execution Guide
176
+
177
+ **Query**: {query}
178
+
179
+ **Base URL**: {base_url}
180
+
181
+ ### Option 1: curl (CSV)
182
+ ```bash
183
+ curl -H "X-API-Key: YOUR_API_KEY" \\
184
+ "{csv_url}"
185
+ ```
186
+
187
+ ### Option 2: curl (JSON)
188
+ ```bash
189
+ curl -H "X-API-Key: YOUR_API_KEY" \\
190
+ "{json_url}"
191
+ ```
192
+
193
+ ### Option 3: curl (Parquet download link)
194
+ ```bash
195
+ curl -H "X-API-Key: YOUR_API_KEY" \\
196
+ "{parquet_url}"
197
+ ```
198
+
199
+ ### Option 4: Python (requests)
200
+ ```python
201
+ import requests
202
+
203
+ headers = {{"X-API-Key": "YOUR_API_KEY"}}
204
+ resp = requests.get("{csv_url}", headers=headers)
205
+ print(resp.text)
206
+ ```
207
+
208
+ ### Option 5: Python (aiohttp)
209
+ ```python
210
+ import aiohttp
211
+ import asyncio
212
+
213
+ async def fetch():
214
+ headers = {{"X-API-Key": "YOUR_API_KEY"}}
215
+ async with aiohttp.ClientSession() as session:
216
+ async with session.get("{csv_url}", headers=headers) as resp:
217
+ data = await resp.text()
218
+ print(data)
219
+
220
+ asyncio.run(fetch())
221
+ ```
222
+
223
+ ### Option 6: JavaScript (fetch)
224
+ ```javascript
225
+ const resp = await fetch("{csv_url}", {{
226
+ headers: {{ "X-API-Key": "YOUR_API_KEY" }}
227
+ }});
228
+ const data = await resp.text();
229
+ console.log(data);
230
+ ```
231
+
232
+ ### Option 7: Python Client Library
233
+ ```python
234
+ from defistream import DeFiStream
235
+
236
+ client = DeFiStream(api_key="YOUR_API_KEY")
237
+ df = client.{protocol}.{event_type}({client_args}).as_df()
238
+ print(df)
239
+ ```
240
+
241
+ ### Format Options
242
+ - `format=json` — JSON response (max 10,000 blocks)
243
+ - `format=csv` — CSV streaming (unlimited blocks)
244
+ - `format=parquet` — Parquet file (unlimited blocks)
245
+ - `link=true` — Returns shareable download link (1 hour expiry, CSV/Parquet only)
246
+ """
247
+ return guide
@@ -0,0 +1,65 @@
1
+ """Static MCP resources providing context to LLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PROTOCOLS_TEXT = """\
6
+ DeFiStream supported protocols and their query builder tools:
7
+
8
+ - erc20 — ERC-20 token transfer events (USDT, USDC, WETH, …) → erc20_query_builder
9
+ - native_token — Native blockchain token transfers (ETH, MATIC, BNB, …) → native_token_query_builder
10
+ - aave_v3 — AAVE V3 lending protocol events (deposit, withdraw, borrow, repay, flashloan, liquidation) → aave_v3_query_builder
11
+ - uniswap_v3 — Uniswap V3 DEX events (swap, deposit, withdraw, collect) → uniswap_v3_query_builder
12
+ - lido — Lido liquid staking events (deposit, withdrawal_request, withdrawal_claimed, l2_deposit, l2_withdrawal_request) → lido_query_builder
13
+ - stader — Stader ETHx staking events → stader_query_builder
14
+ - threshold — Threshold tBTC bridge events → threshold_query_builder
15
+
16
+ Workflow:
17
+ 1. Use a protocol query builder to create a query path.
18
+ 2. Pass the query path to execute_query() for JSON results, or download_query() for CSV/Parquet.
19
+ 3. Use supported_networks(protocol) to check if a network is valid before building queries.
20
+ """
21
+
22
+ API_LIMITS_TEXT = """\
23
+ DeFiStream API limits and constraints:
24
+
25
+ Block range limits per request:
26
+ - JSON format (execute_query): max 10,000 blocks
27
+ - CSV format (download_query): max 1,000,000 blocks
28
+ - Parquet format (download_query): max 1,000,000 blocks
29
+
30
+ Range specification (one required):
31
+ - block_start + block_end (integer block numbers)
32
+ - since + until (ISO 8601 timestamps or Unix seconds)
33
+ Cannot mix block range with time range.
34
+
35
+ execute_query tool:
36
+ - Returns JSON, capped at the configured row limit (default 200).
37
+ - For larger result sets, narrow the block/time range or use download_query.
38
+
39
+ download_query tool:
40
+ - Streams full result to a local file (CSV or Parquet).
41
+ - No row cap — limited only by block range.
42
+
43
+ Quota:
44
+ - Each request costs Computation Units (CU).
45
+ - Cost depends on protocol, format, token, block range, and verbose flag.
46
+ - Response headers report remaining quota.
47
+
48
+ Rate limiting:
49
+ - basic plan: 60 req/min
50
+ - pro plan: 300 req/min
51
+ """
52
+
53
+
54
+ def register_resources(mcp): # noqa: ANN001
55
+ """Register static resources on the FastMCP instance."""
56
+
57
+ @mcp.resource("defistream://protocols")
58
+ def protocols_resource() -> str:
59
+ """Overview of all DeFiStream protocols."""
60
+ return PROTOCOLS_TEXT
61
+
62
+ @mcp.resource("defistream://api-limits")
63
+ def api_limits_resource() -> str:
64
+ """API limits, block range caps, and quota information."""
65
+ return API_LIMITS_TEXT
@@ -0,0 +1,61 @@
1
+ """DeFiStream MCP Server — entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from .api_client import init_client
11
+ from .config import ServerConfig
12
+ from .resources import register_resources
13
+ from .tools import register_tools
14
+
15
+ # All logging goes to stderr so stdout stays clean for stdio transport.
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
19
+ stream=sys.stderr,
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def create_server(config: ServerConfig) -> FastMCP:
25
+ """Build and wire the FastMCP server instance."""
26
+ # Pass host/port to FastMCP constructor for SSE transport
27
+ mcp = FastMCP("defistream", host=config.host, port=config.port)
28
+ init_client(config)
29
+ register_resources(mcp)
30
+ register_tools(mcp, config)
31
+ return mcp
32
+
33
+
34
+ def main() -> None:
35
+ """CLI entry point (``defistream-mcp``)."""
36
+ try:
37
+ config = ServerConfig.from_env()
38
+ except ValueError as exc:
39
+ logger.error("Configuration error: %s", exc)
40
+ sys.exit(1)
41
+
42
+ logger.info(
43
+ "Starting DeFiStream MCP server (transport=%s, base_url=%s)",
44
+ config.transport,
45
+ config.base_url,
46
+ )
47
+
48
+ server = create_server(config)
49
+
50
+ if config.transport == "http":
51
+ logger.info("HTTP server listening on %s:%d", config.host, config.port)
52
+ server.run(transport="streamable-http")
53
+ elif config.transport == "sse":
54
+ logger.info("SSE server listening on %s:%d", config.host, config.port)
55
+ server.run(transport="sse")
56
+ else:
57
+ server.run(transport="stdio")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()