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.
- defistream_mcp/__init__.py +3 -0
- defistream_mcp/api_client.py +102 -0
- defistream_mcp/config.py +99 -0
- defistream_mcp/formatters.py +247 -0
- defistream_mcp/resources.py +65 -0
- defistream_mcp/server.py +61 -0
- defistream_mcp/tools.py +727 -0
- defistream_mcp-0.2.0.dist-info/METADATA +224 -0
- defistream_mcp-0.2.0.dist-info/RECORD +11 -0
- defistream_mcp-0.2.0.dist-info/WHEEL +4 -0
- defistream_mcp-0.2.0.dist-info/entry_points.txt +2 -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()
|
defistream_mcp/config.py
ADDED
|
@@ -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
|
defistream_mcp/server.py
ADDED
|
@@ -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()
|