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.

Files changed (39) hide show
  1. spice_mcp/__init__.py +2 -0
  2. spice_mcp/adapters/__init__.py +0 -0
  3. spice_mcp/adapters/dune/__init__.py +10 -0
  4. spice_mcp/adapters/dune/admin.py +94 -0
  5. spice_mcp/adapters/dune/cache.py +185 -0
  6. spice_mcp/adapters/dune/client.py +255 -0
  7. spice_mcp/adapters/dune/extract.py +1461 -0
  8. spice_mcp/adapters/dune/helpers.py +11 -0
  9. spice_mcp/adapters/dune/transport.py +70 -0
  10. spice_mcp/adapters/dune/types.py +52 -0
  11. spice_mcp/adapters/dune/typing_utils.py +10 -0
  12. spice_mcp/adapters/dune/urls.py +126 -0
  13. spice_mcp/adapters/http_client.py +156 -0
  14. spice_mcp/config.py +81 -0
  15. spice_mcp/core/__init__.py +0 -0
  16. spice_mcp/core/errors.py +101 -0
  17. spice_mcp/core/models.py +88 -0
  18. spice_mcp/core/ports.py +69 -0
  19. spice_mcp/logging/query_history.py +131 -0
  20. spice_mcp/mcp/__init__.py +1 -0
  21. spice_mcp/mcp/server.py +546 -0
  22. spice_mcp/mcp/tools/__init__.py +1 -0
  23. spice_mcp/mcp/tools/base.py +41 -0
  24. spice_mcp/mcp/tools/execute_query.py +425 -0
  25. spice_mcp/mcp/tools/sui_package_overview.py +56 -0
  26. spice_mcp/observability/__init__.py +0 -0
  27. spice_mcp/observability/logging.py +18 -0
  28. spice_mcp/polars_utils.py +15 -0
  29. spice_mcp/py.typed +1 -0
  30. spice_mcp/service_layer/__init__.py +0 -0
  31. spice_mcp/service_layer/discovery_service.py +20 -0
  32. spice_mcp/service_layer/query_admin_service.py +26 -0
  33. spice_mcp/service_layer/query_service.py +118 -0
  34. spice_mcp/service_layer/sui_service.py +131 -0
  35. spice_mcp-0.1.0.dist-info/METADATA +133 -0
  36. spice_mcp-0.1.0.dist-info/RECORD +39 -0
  37. spice_mcp-0.1.0.dist-info/WHEEL +4 -0
  38. spice_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  39. spice_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def get_dune_network_name(network: str) -> str:
5
+ if network == 'avalanche':
6
+ return 'avalanche_c'
7
+ elif network == 'bsc':
8
+ return 'bnb'
9
+ else:
10
+ return network
11
+
@@ -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
@@ -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
@@ -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)