spice-mcp 0.1.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.
Potentially problematic release.
This version of spice-mcp might be problematic. Click here for more details.
- spice_mcp/__init__.py +2 -0
- spice_mcp/adapters/__init__.py +0 -0
- spice_mcp/adapters/dune/__init__.py +10 -0
- spice_mcp/adapters/dune/admin.py +94 -0
- spice_mcp/adapters/dune/cache.py +185 -0
- spice_mcp/adapters/dune/client.py +255 -0
- spice_mcp/adapters/dune/extract.py +1461 -0
- spice_mcp/adapters/dune/helpers.py +11 -0
- spice_mcp/adapters/dune/transport.py +70 -0
- spice_mcp/adapters/dune/types.py +52 -0
- spice_mcp/adapters/dune/typing_utils.py +10 -0
- spice_mcp/adapters/dune/urls.py +126 -0
- spice_mcp/adapters/http_client.py +156 -0
- spice_mcp/config.py +81 -0
- spice_mcp/core/__init__.py +0 -0
- spice_mcp/core/errors.py +101 -0
- spice_mcp/core/models.py +88 -0
- spice_mcp/core/ports.py +69 -0
- spice_mcp/logging/query_history.py +131 -0
- spice_mcp/mcp/__init__.py +1 -0
- spice_mcp/mcp/server.py +546 -0
- spice_mcp/mcp/tools/__init__.py +1 -0
- spice_mcp/mcp/tools/base.py +41 -0
- spice_mcp/mcp/tools/execute_query.py +425 -0
- spice_mcp/mcp/tools/sui_package_overview.py +56 -0
- spice_mcp/observability/__init__.py +0 -0
- spice_mcp/observability/logging.py +18 -0
- spice_mcp/polars_utils.py +15 -0
- spice_mcp/py.typed +1 -0
- spice_mcp/service_layer/__init__.py +0 -0
- spice_mcp/service_layer/discovery_service.py +20 -0
- spice_mcp/service_layer/query_admin_service.py +26 -0
- spice_mcp/service_layer/query_service.py +118 -0
- spice_mcp/service_layer/sui_service.py +131 -0
- spice_mcp-0.1.0.dist-info/METADATA +133 -0
- spice_mcp-0.1.0.dist-info/RECORD +39 -0
- spice_mcp-0.1.0.dist-info/WHEEL +4 -0
- spice_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- spice_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..http_client import HttpClient
|
|
8
|
+
|
|
9
|
+
_HTTP_CLIENT: HttpClient | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def use_http_client(client: HttpClient | None):
|
|
14
|
+
global _HTTP_CLIENT
|
|
15
|
+
previous = _HTTP_CLIENT
|
|
16
|
+
if client is not None:
|
|
17
|
+
_HTTP_CLIENT = client
|
|
18
|
+
try:
|
|
19
|
+
yield
|
|
20
|
+
finally:
|
|
21
|
+
_HTTP_CLIENT = previous
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_http_client() -> HttpClient | None:
|
|
25
|
+
return _HTTP_CLIENT
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def request(
|
|
29
|
+
method: str,
|
|
30
|
+
url: str,
|
|
31
|
+
*,
|
|
32
|
+
headers: Mapping[str, str],
|
|
33
|
+
timeout: float,
|
|
34
|
+
json: Any | None = None,
|
|
35
|
+
data: Any | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
client = _HTTP_CLIENT
|
|
38
|
+
if client is not None:
|
|
39
|
+
return client.request(
|
|
40
|
+
method,
|
|
41
|
+
url,
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
json=json,
|
|
45
|
+
data=data,
|
|
46
|
+
)
|
|
47
|
+
import requests
|
|
48
|
+
|
|
49
|
+
return requests.request(
|
|
50
|
+
method,
|
|
51
|
+
url,
|
|
52
|
+
headers=headers,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
json=json,
|
|
55
|
+
data=data,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get(url: str, *, headers: Mapping[str, str], timeout: float):
|
|
60
|
+
return request("GET", url, headers=headers, timeout=timeout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def post(
|
|
64
|
+
url: str,
|
|
65
|
+
*,
|
|
66
|
+
headers: Mapping[str, str],
|
|
67
|
+
json: Any | None = None,
|
|
68
|
+
timeout: float,
|
|
69
|
+
):
|
|
70
|
+
return request("POST", url, headers=headers, json=json, timeout=timeout)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
Query = int | str
|
|
9
|
+
|
|
10
|
+
Performance = Literal['medium', 'large']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Execution(TypedDict):
|
|
14
|
+
execution_id: str
|
|
15
|
+
timestamp: NotRequired[int | None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecuteKwargs(TypedDict):
|
|
19
|
+
query_id: int | None
|
|
20
|
+
api_key: str | None
|
|
21
|
+
parameters: Mapping[str, Any] | None
|
|
22
|
+
performance: Performance
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PollKwargs(TypedDict):
|
|
26
|
+
api_key: str | None
|
|
27
|
+
poll_interval: float
|
|
28
|
+
verbose: bool
|
|
29
|
+
timeout_seconds: float | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RetrievalKwargs(TypedDict):
|
|
33
|
+
limit: int | None
|
|
34
|
+
offset: int | None
|
|
35
|
+
sample_count: int | None
|
|
36
|
+
sort_by: str | None
|
|
37
|
+
columns: Sequence[str] | None
|
|
38
|
+
extras: Mapping[str, Any] | None
|
|
39
|
+
types: Sequence[type[pl.DataType]] | Mapping[str, type[pl.DataType]] | None
|
|
40
|
+
all_types: (
|
|
41
|
+
Sequence[type[pl.DataType]] | Mapping[str, type[pl.DataType]] | None
|
|
42
|
+
)
|
|
43
|
+
verbose: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OutputKwargs(TypedDict):
|
|
47
|
+
execute_kwargs: ExecuteKwargs
|
|
48
|
+
result_kwargs: RetrievalKwargs
|
|
49
|
+
cache: bool
|
|
50
|
+
save_to_cache: bool
|
|
51
|
+
cache_dir: str | None
|
|
52
|
+
include_execution: bool
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def resolve_raw_sql_template_id() -> int:
|
|
5
|
+
"""Return a stable template ID used for executing raw SQL text.
|
|
6
|
+
|
|
7
|
+
Tests stub HTTP boundaries and only require a consistent integer. This
|
|
8
|
+
placeholder can be adjusted if upstream semantics change.
|
|
9
|
+
"""
|
|
10
|
+
return 0
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _base_url() -> str:
|
|
10
|
+
base = os.getenv('DUNE_API_URL', 'https://api.dune.com/api/v1').rstrip('/')
|
|
11
|
+
return base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
url_templates = {
|
|
15
|
+
'execution_status': _base_url() + '/execution/{execution_id}/status',
|
|
16
|
+
'execution_results': _base_url() + '/execution/{execution_id}/results/csv',
|
|
17
|
+
'query_execution': _base_url() + '/query/{query_id}/execute',
|
|
18
|
+
'query_results': _base_url() + '/query/{query_id}/results/csv',
|
|
19
|
+
'query_results_json': _base_url() + '/query/{query_id}/results',
|
|
20
|
+
'query_create': _base_url() + '/query/',
|
|
21
|
+
'query': _base_url() + '/query/{query_id}',
|
|
22
|
+
'query_fork': _base_url() + '/query/{query_id}/fork',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_query_execute_url(query: int | str) -> str:
|
|
27
|
+
if isinstance(query, str):
|
|
28
|
+
return query
|
|
29
|
+
elif isinstance(query, int):
|
|
30
|
+
return url_templates['query_execution'].format(query_id=query)
|
|
31
|
+
else:
|
|
32
|
+
raise Exception('unknown query format: ' + str(type(query)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_query_results_url(
|
|
36
|
+
query: int | str, parameters: dict[str, Any], csv: bool = True
|
|
37
|
+
) -> str:
|
|
38
|
+
query_id = get_query_id(query)
|
|
39
|
+
if csv:
|
|
40
|
+
template = url_templates['query_results']
|
|
41
|
+
else:
|
|
42
|
+
template = url_templates['query_results_json']
|
|
43
|
+
url = template.format(query_id=query_id)
|
|
44
|
+
|
|
45
|
+
parameters = dict(parameters.items())
|
|
46
|
+
if 'query_parameters' in parameters:
|
|
47
|
+
parameters['params'] = parameters.pop('query_parameters')
|
|
48
|
+
for key, value in list(parameters.items()):
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
del parameters[key]
|
|
51
|
+
for subkey, subvalue in value.items():
|
|
52
|
+
parameters[key + '.' + subkey] = subvalue
|
|
53
|
+
|
|
54
|
+
return add_args_to_url(url, parameters=parameters)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_execution_status_url(execution_id: str) -> str:
|
|
58
|
+
return url_templates['execution_status'].format(execution_id=execution_id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_execution_results_url(
|
|
62
|
+
execution_id: str, parameters: Mapping[str, Any]
|
|
63
|
+
) -> str:
|
|
64
|
+
url = url_templates['execution_results'].format(execution_id=execution_id)
|
|
65
|
+
return add_args_to_url(url, parameters=parameters)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_args_to_url(url: str, parameters: Mapping[str, Any]) -> str:
|
|
69
|
+
"""Append query params to a base URL.
|
|
70
|
+
|
|
71
|
+
- Flattens nested dicts like {"a": {"b": 1}} -> "a.b=1"
|
|
72
|
+
- Joins lists via commas
|
|
73
|
+
- Skips None values
|
|
74
|
+
"""
|
|
75
|
+
# Flatten nested dicts once (shallow nesting like key: {sub: val})
|
|
76
|
+
flat: dict[str, Any] = {}
|
|
77
|
+
for key, value in parameters.items():
|
|
78
|
+
if value is None:
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
for subkey, subvalue in value.items():
|
|
82
|
+
flat[f"{key}.{subkey}"] = subvalue
|
|
83
|
+
else:
|
|
84
|
+
flat[key] = value
|
|
85
|
+
|
|
86
|
+
# Build query string
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
for key, value in flat.items():
|
|
89
|
+
if value is None:
|
|
90
|
+
continue
|
|
91
|
+
if isinstance(value, list):
|
|
92
|
+
value = ','.join(str(item) for item in value)
|
|
93
|
+
parts.append(f"{key}={value}")
|
|
94
|
+
|
|
95
|
+
sep = '&' if '?' in url else '?'
|
|
96
|
+
return url + (sep if parts else '') + '&'.join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_api_key() -> str:
|
|
100
|
+
"""get dune api key"""
|
|
101
|
+
return os.environ['DUNE_API_KEY']
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_query_id(query: str | int) -> int:
|
|
105
|
+
"""get id of a query"""
|
|
106
|
+
if isinstance(query, int):
|
|
107
|
+
return query
|
|
108
|
+
elif isinstance(query, str):
|
|
109
|
+
m = re.search(r"/api/v1/query/(\d+)", query)
|
|
110
|
+
if m:
|
|
111
|
+
query = m.group(1)
|
|
112
|
+
else:
|
|
113
|
+
m2 = re.search(r"dune\.com/queries/(\d+)", query)
|
|
114
|
+
if m2:
|
|
115
|
+
query = m2.group(1)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
return int(query)
|
|
119
|
+
except ValueError:
|
|
120
|
+
raise Exception('invalid query id: ' + str(query))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_headers(*, api_key: str | None = None) -> Mapping[str, str]:
|
|
124
|
+
if api_key is None:
|
|
125
|
+
api_key = get_api_key()
|
|
126
|
+
return {'X-Dune-API-Key': api_key}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""HTTP client utilities shared across adapters.
|
|
2
|
+
|
|
3
|
+
This module centralises retry/backoff behaviour, timeout defaults, and structured
|
|
4
|
+
logging so that transport concerns stay consistent between adapters and higher
|
|
5
|
+
level services. It is intentionally lightweight (requests-based) to avoid
|
|
6
|
+
imposing additional dependencies on downstream environments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("spice_mcp.http")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class HttpClientConfig:
|
|
24
|
+
"""Configuration knobs for :class:`HttpClient`."""
|
|
25
|
+
|
|
26
|
+
timeout_seconds: float = 15.0
|
|
27
|
+
max_retries: int = 3
|
|
28
|
+
backoff_initial: float = 0.35
|
|
29
|
+
backoff_max: float = 5.0
|
|
30
|
+
jitter_range: tuple[float, float] = (1.25, 2.25)
|
|
31
|
+
retry_statuses: tuple[int, ...] = (408, 409, 425, 429, 500, 502, 503, 504)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpClient:
|
|
35
|
+
"""Tiny wrapper over :mod:`requests` with consistent retry semantics."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
config: HttpClientConfig,
|
|
40
|
+
*,
|
|
41
|
+
session: requests.Session | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._config = config
|
|
44
|
+
self._session = session or requests.Session()
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ public
|
|
47
|
+
def request(
|
|
48
|
+
self,
|
|
49
|
+
method: str,
|
|
50
|
+
url: str,
|
|
51
|
+
*,
|
|
52
|
+
headers: Mapping[str, str] | None = None,
|
|
53
|
+
params: Mapping[str, object] | None = None,
|
|
54
|
+
json: object | None = None,
|
|
55
|
+
data: object | None = None,
|
|
56
|
+
timeout: float | None = None,
|
|
57
|
+
ok_statuses: Iterable[int] | None = None,
|
|
58
|
+
) -> requests.Response:
|
|
59
|
+
"""Perform an HTTP request with exponential backoff.
|
|
60
|
+
|
|
61
|
+
``ok_statuses`` may be provided to allow responses that would otherwise
|
|
62
|
+
raise for status. Any HTTP exception that is not recoverable (non
|
|
63
|
+
retryable status or retries exhausted) is re-raised to the caller.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
attempt = 0
|
|
67
|
+
backoff = self._config.backoff_initial
|
|
68
|
+
timeout_value = timeout or self._config.timeout_seconds
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
start = time.perf_counter()
|
|
72
|
+
try:
|
|
73
|
+
response = self._session.request(
|
|
74
|
+
method,
|
|
75
|
+
url,
|
|
76
|
+
headers=_clone_mapping(headers),
|
|
77
|
+
params=params,
|
|
78
|
+
json=json,
|
|
79
|
+
data=data,
|
|
80
|
+
timeout=timeout_value,
|
|
81
|
+
)
|
|
82
|
+
except requests.RequestException as exc: # network/timeout failure
|
|
83
|
+
logger.warning(
|
|
84
|
+
"http_request_error",
|
|
85
|
+
extra={
|
|
86
|
+
"method": method,
|
|
87
|
+
"url": url,
|
|
88
|
+
"attempt": attempt,
|
|
89
|
+
"error": str(exc),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
if attempt >= self._config.max_retries:
|
|
93
|
+
raise
|
|
94
|
+
_sleep(backoff, self._config)
|
|
95
|
+
backoff = min(self._config.backoff_max, backoff * 2)
|
|
96
|
+
attempt += 1
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
100
|
+
status = response.status_code
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
"http_request",
|
|
104
|
+
extra={
|
|
105
|
+
"method": method,
|
|
106
|
+
"url": url,
|
|
107
|
+
"status": status,
|
|
108
|
+
"attempt": attempt,
|
|
109
|
+
"duration_ms": duration_ms,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if _should_retry(status, attempt, self._config):
|
|
114
|
+
_sleep(backoff, self._config)
|
|
115
|
+
backoff = min(self._config.backoff_max, backoff * 2)
|
|
116
|
+
attempt += 1
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if ok_statuses and status in ok_statuses:
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
except requests.HTTPError as exc: # Non-OK terminal status
|
|
125
|
+
logger.error(
|
|
126
|
+
"http_request_failed",
|
|
127
|
+
extra={
|
|
128
|
+
"method": method,
|
|
129
|
+
"url": url,
|
|
130
|
+
"status": status,
|
|
131
|
+
"attempt": attempt,
|
|
132
|
+
"error": str(exc),
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
def _should_retry(status: int, attempt: int, config: HttpClientConfig) -> bool:
|
|
142
|
+
return (
|
|
143
|
+
status in config.retry_statuses
|
|
144
|
+
and attempt < config.max_retries
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sleep(backoff: float, config: HttpClientConfig) -> None:
|
|
149
|
+
jitter = random.uniform(*config.jitter_range)
|
|
150
|
+
time.sleep(backoff * jitter)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _clone_mapping(mapping: Mapping[str, str] | None) -> MutableMapping[str, str] | None:
|
|
154
|
+
if mapping is None:
|
|
155
|
+
return None
|
|
156
|
+
return dict(mapping)
|
spice_mcp/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .adapters.http_client import HttpClientConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DuneConfig:
|
|
12
|
+
"""Dune Analytics API configuration."""
|
|
13
|
+
|
|
14
|
+
api_key: str # Required
|
|
15
|
+
api_url: str = "https://api.dune.com/api/v1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CacheConfig:
|
|
20
|
+
"""Cache configuration."""
|
|
21
|
+
|
|
22
|
+
mode: Literal["enabled", "read_only", "refresh", "disabled"] = "enabled"
|
|
23
|
+
cache_dir: str | None = None
|
|
24
|
+
max_size_mb: int = 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class LoggingConfig:
|
|
29
|
+
"""Logging configuration."""
|
|
30
|
+
|
|
31
|
+
query_history_path: str | None = None
|
|
32
|
+
artifact_root: str | None = None
|
|
33
|
+
enabled: bool = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
"""Main application configuration."""
|
|
39
|
+
|
|
40
|
+
dune: DuneConfig
|
|
41
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
42
|
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
43
|
+
http: HttpClientConfig = field(default_factory=HttpClientConfig)
|
|
44
|
+
max_concurrent_queries: int = 5
|
|
45
|
+
default_timeout_seconds: int = 30
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_env(cls) -> Config:
|
|
49
|
+
"""Load configuration from environment variables."""
|
|
50
|
+
api_key = os.getenv("DUNE_API_KEY")
|
|
51
|
+
if not api_key:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"DUNE_API_KEY environment variable is required. "
|
|
54
|
+
"Get your API key from https://dune.com/settings/api"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return cls(
|
|
58
|
+
dune=DuneConfig(
|
|
59
|
+
api_key=api_key,
|
|
60
|
+
api_url=os.getenv("DUNE_API_URL", "https://api.dune.com/api/v1"),
|
|
61
|
+
),
|
|
62
|
+
cache=CacheConfig(
|
|
63
|
+
mode=os.getenv("SPICE_CACHE_MODE", "enabled"),
|
|
64
|
+
cache_dir=os.getenv("SPICE_CACHE_DIR"),
|
|
65
|
+
max_size_mb=int(os.getenv("SPICE_CACHE_MAX_SIZE_MB", "500")),
|
|
66
|
+
),
|
|
67
|
+
logging=LoggingConfig(
|
|
68
|
+
query_history_path=os.getenv("SPICE_QUERY_HISTORY"),
|
|
69
|
+
artifact_root=os.getenv("SPICE_ARTIFACT_ROOT"),
|
|
70
|
+
enabled=os.getenv("SPICE_LOGGING_ENABLED", "true").lower()
|
|
71
|
+
== "true",
|
|
72
|
+
),
|
|
73
|
+
http=HttpClientConfig(
|
|
74
|
+
timeout_seconds=float(os.getenv("SPICE_HTTP_TIMEOUT", "15")),
|
|
75
|
+
max_retries=int(os.getenv("SPICE_HTTP_RETRIES", "3")),
|
|
76
|
+
backoff_initial=float(os.getenv("SPICE_HTTP_BACKOFF", "0.35")),
|
|
77
|
+
backoff_max=float(os.getenv("SPICE_HTTP_BACKOFF_MAX", "5.0")),
|
|
78
|
+
),
|
|
79
|
+
default_timeout_seconds=int(os.getenv("SPICE_TIMEOUT_SECONDS", "30")),
|
|
80
|
+
max_concurrent_queries=int(os.getenv("SPICE_MAX_CONCURRENT_QUERIES", "5")),
|
|
81
|
+
)
|
|
File without changes
|
spice_mcp/core/errors.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class MCPError:
|
|
9
|
+
code: str
|
|
10
|
+
message: str
|
|
11
|
+
suggestions: tuple[str, ...] = ()
|
|
12
|
+
context: dict[str, Any] | None = None
|
|
13
|
+
|
|
14
|
+
def to_payload(self) -> dict[str, Any]:
|
|
15
|
+
payload: dict[str, Any] = {
|
|
16
|
+
"code": self.code,
|
|
17
|
+
"message": self.message,
|
|
18
|
+
"data": {"suggestions": list(self.suggestions)},
|
|
19
|
+
}
|
|
20
|
+
if self.context:
|
|
21
|
+
payload["context"] = _redact_context(self.context)
|
|
22
|
+
return payload
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def categorize_error(error: Exception) -> MCPError:
|
|
26
|
+
"""Return a structured MCPError with actionable suggestions."""
|
|
27
|
+
msg = str(error) if error is not None else ""
|
|
28
|
+
low = msg.lower()
|
|
29
|
+
|
|
30
|
+
if isinstance(error, TimeoutError) or "timeout" in low or "timed out" in low:
|
|
31
|
+
return MCPError(
|
|
32
|
+
code="QUERY_TIMEOUT",
|
|
33
|
+
message=msg or "Query timed out",
|
|
34
|
+
suggestions=(
|
|
35
|
+
"Increase timeout_seconds (e.g., 60 or 120).",
|
|
36
|
+
"Reduce the scan window or LIMIT.",
|
|
37
|
+
"Add WHERE filters to narrow the dataset.",
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if "429" in low or "rate limit" in low:
|
|
42
|
+
return MCPError(
|
|
43
|
+
code="RATE_LIMIT",
|
|
44
|
+
message=msg or "Dune API rate limit hit.",
|
|
45
|
+
suggestions=(
|
|
46
|
+
"Retry shortly; the client already applies exponential backoff.",
|
|
47
|
+
"Use cached results or smaller LIMIT windows.",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if "401" in low or "unauthorized" in low or "api key" in low:
|
|
52
|
+
return MCPError(
|
|
53
|
+
code="AUTH_ERROR",
|
|
54
|
+
message=msg or "Authentication failed.",
|
|
55
|
+
suggestions=(
|
|
56
|
+
"Ensure DUNE_API_KEY is exported before launching Codex.",
|
|
57
|
+
"Rotate or verify the API key in Dune settings.",
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if "query failed" in low or "sql" in low or "syntax" in low:
|
|
62
|
+
return MCPError(
|
|
63
|
+
code="QUERY_ERROR",
|
|
64
|
+
message=msg or "Query execution failed.",
|
|
65
|
+
suggestions=(
|
|
66
|
+
"Validate SQL and parameters in the Dune UI.",
|
|
67
|
+
"Check schema/table/column names.",
|
|
68
|
+
"Ensure parameter names and types match the query definition.",
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return MCPError(
|
|
73
|
+
code="UNKNOWN_ERROR",
|
|
74
|
+
message=msg or type(error).__name__,
|
|
75
|
+
suggestions=(
|
|
76
|
+
"Retry the request.",
|
|
77
|
+
"Inspect error details for additional context.",
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def error_response(error: Exception, *, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
83
|
+
"""Serialize an error into the standard MCP envelope."""
|
|
84
|
+
cat = categorize_error(error)
|
|
85
|
+
if context:
|
|
86
|
+
cat.context = context
|
|
87
|
+
payload: dict[str, Any] = {
|
|
88
|
+
"ok": False,
|
|
89
|
+
"error": cat.to_payload(),
|
|
90
|
+
}
|
|
91
|
+
return payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _redact_context(ctx: dict[str, Any]) -> dict[str, Any]:
|
|
95
|
+
redacted: dict[str, Any] = {}
|
|
96
|
+
for key, value in ctx.items():
|
|
97
|
+
if isinstance(value, str) and any(token in key.lower() for token in ("api_key", "token", "secret")):
|
|
98
|
+
redacted[key] = "****"
|
|
99
|
+
else:
|
|
100
|
+
redacted[key] = value
|
|
101
|
+
return redacted
|
spice_mcp/core/models.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class QueryRequest:
|
|
10
|
+
"""Normalized representation of a Dune query request."""
|
|
11
|
+
|
|
12
|
+
query: int | str
|
|
13
|
+
parameters: Mapping[str, Any] | None = None
|
|
14
|
+
refresh: bool = False
|
|
15
|
+
max_age: float | None = None
|
|
16
|
+
poll: bool = True
|
|
17
|
+
timeout_seconds: float | None = None
|
|
18
|
+
limit: int | None = None
|
|
19
|
+
offset: int | None = None
|
|
20
|
+
sample_count: int | None = None
|
|
21
|
+
sort_by: str | None = None
|
|
22
|
+
columns: Sequence[str] | None = None
|
|
23
|
+
extras: Mapping[str, Any] | None = None
|
|
24
|
+
performance: str | None = None
|
|
25
|
+
include_execution: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ResultPreview:
|
|
30
|
+
rowcount: int
|
|
31
|
+
columns: list[str]
|
|
32
|
+
data_preview: list[dict[str, Any]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ResultMetadata:
|
|
37
|
+
"""Subset of Dune execution metadata surfaced to MCP clients."""
|
|
38
|
+
|
|
39
|
+
execution: dict[str, Any]
|
|
40
|
+
duration_ms: int
|
|
41
|
+
metadata: dict[str, Any] | None = None
|
|
42
|
+
next_offset: int | None = None
|
|
43
|
+
next_uri: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class QueryResult:
|
|
48
|
+
"""Normalized result returned by QueryExecutor implementations."""
|
|
49
|
+
|
|
50
|
+
preview: ResultPreview
|
|
51
|
+
info: ResultMetadata
|
|
52
|
+
lazyframe: Any | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class SchemaMatch:
|
|
57
|
+
schema: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class TableSummary:
|
|
62
|
+
schema: str
|
|
63
|
+
table: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class TableColumn:
|
|
68
|
+
name: str
|
|
69
|
+
dune_type: str | None = None
|
|
70
|
+
polars_dtype: str | None = None
|
|
71
|
+
comment: str | None = None
|
|
72
|
+
extra: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class TableDescription:
|
|
77
|
+
fully_qualified_name: str
|
|
78
|
+
columns: list[TableColumn] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class SuiPackageOverview:
|
|
83
|
+
package_ids: list[str]
|
|
84
|
+
window_hours: int
|
|
85
|
+
events_preview: list[dict[str, Any]] | None = None
|
|
86
|
+
transactions_preview: list[dict[str, Any]] | None = None
|
|
87
|
+
objects_preview: list[dict[str, Any]] | None = None
|
|
88
|
+
stats: dict[str, Any] = field(default_factory=dict)
|