spice-mcp 0.1.4__py3-none-any.whl → 0.1.6__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/adapters/dune/client.py +13 -29
- spice_mcp/adapters/dune/extract.py +1 -1
- spice_mcp/adapters/dune/query_wrapper.py +1 -1
- spice_mcp/adapters/spellbook/explorer.py +97 -17
- spice_mcp/mcp/server.py +149 -111
- spice_mcp/mcp/tools/execute_query.py +13 -21
- spice_mcp/service_layer/verification_service.py +185 -0
- {spice_mcp-0.1.4.dist-info → spice_mcp-0.1.6.dist-info}/METADATA +18 -6
- {spice_mcp-0.1.4.dist-info → spice_mcp-0.1.6.dist-info}/RECORD +12 -11
- {spice_mcp-0.1.4.dist-info → spice_mcp-0.1.6.dist-info}/WHEEL +0 -0
- {spice_mcp-0.1.4.dist-info → spice_mcp-0.1.6.dist-info}/entry_points.txt +0 -0
- {spice_mcp-0.1.4.dist-info → spice_mcp-0.1.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import re
|
|
5
4
|
import time
|
|
6
5
|
from collections.abc import Mapping, Sequence
|
|
7
6
|
from typing import Any
|
|
@@ -47,10 +46,9 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
|
47
46
|
self._ensure_api_key()
|
|
48
47
|
start = time.time()
|
|
49
48
|
q = request.query
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
q = q_rewritten
|
|
49
|
+
# Use native SHOW statements directly - they're faster than information_schema queries
|
|
50
|
+
# See issue #10: https://github.com/Evan-Kim2028/spice-mcp/issues/10
|
|
51
|
+
# Removed rewrite to avoid performance issues with information_schema queries
|
|
54
52
|
result = _execute_dune_query(
|
|
55
53
|
query_or_execution=q,
|
|
56
54
|
verbose=False,
|
|
@@ -200,7 +198,9 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
|
200
198
|
# Internal helpers --------------------------------------------------------------
|
|
201
199
|
def _run_sql(self, sql: str, *, limit: int | None = None) -> pl.DataFrame:
|
|
202
200
|
self._ensure_api_key()
|
|
203
|
-
|
|
201
|
+
# Use native SHOW statements directly - they're faster than information_schema queries
|
|
202
|
+
# See issue #10: https://github.com/Evan-Kim2028/spice-mcp/issues/10
|
|
203
|
+
sql_eff = sql
|
|
204
204
|
df = _execute_dune_query(
|
|
205
205
|
query_or_execution=sql_eff,
|
|
206
206
|
verbose=False,
|
|
@@ -233,28 +233,12 @@ def _build_preview(lf: pl.LazyFrame, columns: list[str], rowcount: int) -> Resul
|
|
|
233
233
|
|
|
234
234
|
|
|
235
235
|
def _maybe_rewrite_show_sql(sql: str) -> str | None:
|
|
236
|
-
"""
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
"""DEPRECATED: This function is no longer used.
|
|
237
|
+
|
|
238
|
+
Native SHOW statements are now used directly as they're faster than
|
|
239
|
+
information_schema queries in Dune. See issue #10 for details.
|
|
240
|
+
|
|
241
|
+
This function is kept for backward compatibility but is not called.
|
|
240
242
|
"""
|
|
241
|
-
|
|
242
|
-
m = re.match(r"^SHOW\s+SCHEMAS\s+LIKE\s+'([^']+)'\s*;?$", s, flags=re.IGNORECASE)
|
|
243
|
-
if m:
|
|
244
|
-
pat = m.group(1)
|
|
245
|
-
return (
|
|
246
|
-
"SELECT schema_name AS Schema FROM information_schema.schemata "
|
|
247
|
-
f"WHERE schema_name LIKE '{pat}'"
|
|
248
|
-
)
|
|
249
|
-
if re.match(r"^SHOW\s+SCHEMAS\s*;?$", s, flags=re.IGNORECASE):
|
|
250
|
-
return "SELECT schema_name AS Schema FROM information_schema.schemata"
|
|
251
|
-
|
|
252
|
-
m = re.match(r"^SHOW\s+TABLES\s+FROM\s+([A-Za-z0-9_\.]+)\s*;?$", s, flags=re.IGNORECASE)
|
|
253
|
-
if m:
|
|
254
|
-
schema = m.group(1)
|
|
255
|
-
return (
|
|
256
|
-
"SELECT table_name AS Table FROM information_schema.tables "
|
|
257
|
-
f"WHERE table_schema = '{schema}'"
|
|
258
|
-
)
|
|
259
|
-
|
|
243
|
+
# Function body kept for reference but not executed
|
|
260
244
|
return None
|
|
@@ -6,8 +6,8 @@ the @overload decorators that FastMCP detects during runtime validation.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from typing import Any
|
|
10
9
|
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any
|
|
11
11
|
|
|
12
12
|
from ..http_client import HttpClient
|
|
13
13
|
from .types import Execution, Performance, Query
|
|
@@ -133,11 +133,43 @@ class SpellbookExplorer(CatalogExplorer):
|
|
|
133
133
|
if not schema_yml.exists():
|
|
134
134
|
schema_yml = sql_file.parent.parent / "schema.yml"
|
|
135
135
|
|
|
136
|
+
# Parse dbt config to get actual Dune table name
|
|
137
|
+
config = self._parse_dbt_config(sql_file)
|
|
138
|
+
|
|
139
|
+
# Ignore templated dbt config values like "{{ target.schema }}"
|
|
140
|
+
def _is_templated(val: Any) -> bool:
|
|
141
|
+
try:
|
|
142
|
+
s = str(val)
|
|
143
|
+
except Exception:
|
|
144
|
+
return False
|
|
145
|
+
return "{{" in s and "}}" in s
|
|
146
|
+
|
|
147
|
+
raw_schema = config.get("schema")
|
|
148
|
+
raw_alias = config.get("alias")
|
|
149
|
+
|
|
150
|
+
dune_schema = (
|
|
151
|
+
raw_schema.strip() if isinstance(raw_schema, str) else raw_schema
|
|
152
|
+
)
|
|
153
|
+
dune_alias = (
|
|
154
|
+
raw_alias.strip() if isinstance(raw_alias, str) else raw_alias
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Fall back to original names when values are templated or empty
|
|
158
|
+
if not dune_schema or _is_templated(dune_schema):
|
|
159
|
+
dune_schema = schema_name
|
|
160
|
+
if not dune_alias or _is_templated(dune_alias):
|
|
161
|
+
dune_alias = model_name
|
|
162
|
+
|
|
163
|
+
dune_table = f"{dune_schema}.{dune_alias}"
|
|
164
|
+
|
|
136
165
|
models[schema_name].append({
|
|
137
166
|
"name": model_name,
|
|
138
167
|
"file": sql_file,
|
|
139
168
|
"schema_yml": schema_yml if schema_yml.exists() else None,
|
|
140
169
|
"schema": schema_name,
|
|
170
|
+
"dune_schema": dune_schema,
|
|
171
|
+
"dune_alias": dune_alias,
|
|
172
|
+
"dune_table": dune_table,
|
|
141
173
|
})
|
|
142
174
|
|
|
143
175
|
self._models_cache = models
|
|
@@ -264,14 +296,70 @@ class SpellbookExplorer(CatalogExplorer):
|
|
|
264
296
|
|
|
265
297
|
return []
|
|
266
298
|
|
|
299
|
+
def _parse_dbt_config(self, sql_file: Path) -> dict[str, str]:
|
|
300
|
+
"""
|
|
301
|
+
Parse dbt config block from SQL file to extract schema and alias.
|
|
302
|
+
|
|
303
|
+
Looks for patterns like:
|
|
304
|
+
{{ config(schema='sui_walrus', alias='base_table') }}
|
|
305
|
+
{{ config(schema="sui_walrus", alias="base_table") }}
|
|
306
|
+
|
|
307
|
+
Returns dict with 'schema' and 'alias' keys, or empty dict if not found.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
with open(sql_file, encoding="utf-8") as f:
|
|
311
|
+
sql = f.read()
|
|
312
|
+
|
|
313
|
+
# Match dbt config block: {{ config(...) }}
|
|
314
|
+
# Use non-greedy match to get first config block
|
|
315
|
+
config_match = re.search(
|
|
316
|
+
r"{{\s*config\s*\((.*?)\)\s*}}",
|
|
317
|
+
sql,
|
|
318
|
+
re.IGNORECASE | re.DOTALL,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if not config_match:
|
|
322
|
+
return {}
|
|
323
|
+
|
|
324
|
+
config_content = config_match.group(1)
|
|
325
|
+
result: dict[str, str] = {}
|
|
326
|
+
|
|
327
|
+
# Extract schema parameter (supports single and double quotes)
|
|
328
|
+
schema_match = re.search(
|
|
329
|
+
r"schema\s*=\s*['\"]([^'\"]+)['\"]",
|
|
330
|
+
config_content,
|
|
331
|
+
re.IGNORECASE,
|
|
332
|
+
)
|
|
333
|
+
if schema_match:
|
|
334
|
+
result["schema"] = schema_match.group(1)
|
|
335
|
+
|
|
336
|
+
# Extract alias parameter (supports single and double quotes)
|
|
337
|
+
alias_match = re.search(
|
|
338
|
+
r"alias\s*=\s*['\"]([^'\"]+)['\"]",
|
|
339
|
+
config_content,
|
|
340
|
+
re.IGNORECASE,
|
|
341
|
+
)
|
|
342
|
+
if alias_match:
|
|
343
|
+
result["alias"] = alias_match.group(1)
|
|
344
|
+
|
|
345
|
+
return result
|
|
346
|
+
except Exception:
|
|
347
|
+
# On any error (file read, parsing, etc.), return empty dict
|
|
348
|
+
# This allows fallback to using schema_name and model_name
|
|
349
|
+
return {}
|
|
350
|
+
|
|
267
351
|
def _parse_sql_columns(self, sql_file: Path) -> list[TableColumn]:
|
|
268
|
-
"""
|
|
352
|
+
"""
|
|
353
|
+
Parse SQL file to extract column names from SELECT statements.
|
|
354
|
+
|
|
355
|
+
Note: This is a best-effort heuristic and may not be perfect for complex SQL.
|
|
356
|
+
For accurate column information, use Dune's DESCRIBE TABLE or query the actual table.
|
|
357
|
+
"""
|
|
269
358
|
try:
|
|
270
359
|
with open(sql_file, encoding="utf-8") as f:
|
|
271
360
|
sql = f.read()
|
|
272
361
|
|
|
273
|
-
# Look for SELECT ... FROM patterns
|
|
274
|
-
# Match: SELECT col1, col2, col3 FROM ...
|
|
362
|
+
# Look for SELECT ... FROM patterns (simple heuristic)
|
|
275
363
|
select_match = re.search(
|
|
276
364
|
r"SELECT\s+(.+?)\s+FROM",
|
|
277
365
|
sql,
|
|
@@ -280,27 +368,20 @@ class SpellbookExplorer(CatalogExplorer):
|
|
|
280
368
|
|
|
281
369
|
if select_match:
|
|
282
370
|
cols_str = select_match.group(1)
|
|
283
|
-
#
|
|
371
|
+
# Simple split - may not handle all nested cases perfectly
|
|
372
|
+
# This is OK since column info is optional and best-effort
|
|
284
373
|
cols = []
|
|
285
374
|
for col in cols_str.split(","):
|
|
286
375
|
col = col.strip()
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
col = col.split(" AS ", 1)[0].strip()
|
|
290
|
-
elif " " in col and not col.startswith("("):
|
|
291
|
-
# Might be alias without AS
|
|
292
|
-
parts = col.split()
|
|
293
|
-
col = parts[0].strip()
|
|
294
|
-
|
|
295
|
-
# Clean up function calls: function(col) -> col
|
|
296
|
-
col = re.sub(r"^\w+\((.+)\)", r"\1", col)
|
|
376
|
+
# Basic cleanup - remove obvious SQL noise
|
|
377
|
+
col = col.split()[-1] if col else ""
|
|
297
378
|
col = col.strip().strip('"').strip("'")
|
|
298
379
|
|
|
299
|
-
if col and col not in ["*", "DISTINCT"]:
|
|
380
|
+
if col and col not in ["*", "DISTINCT", "FROM"]:
|
|
300
381
|
cols.append(
|
|
301
382
|
TableColumn(
|
|
302
383
|
name=col,
|
|
303
|
-
dune_type="VARCHAR",
|
|
384
|
+
dune_type="VARCHAR",
|
|
304
385
|
polars_dtype="Utf8",
|
|
305
386
|
)
|
|
306
387
|
)
|
|
@@ -310,4 +391,3 @@ class SpellbookExplorer(CatalogExplorer):
|
|
|
310
391
|
pass
|
|
311
392
|
|
|
312
393
|
return []
|
|
313
|
-
|
spice_mcp/mcp/server.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
-
from typing import Any, Literal
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
|
|
7
7
|
os.environ.setdefault("FASTMCP_NO_BANNER", "1")
|
|
8
8
|
os.environ.setdefault("FASTMCP_LOG_LEVEL", "ERROR")
|
|
@@ -33,6 +33,7 @@ from ..logging.query_history import QueryHistory
|
|
|
33
33
|
from ..service_layer.discovery_service import DiscoveryService
|
|
34
34
|
from ..service_layer.query_admin_service import QueryAdminService
|
|
35
35
|
from ..service_layer.query_service import QueryService
|
|
36
|
+
from ..service_layer.verification_service import VerificationService
|
|
36
37
|
from .tools.execute_query import ExecuteQueryTool
|
|
37
38
|
|
|
38
39
|
logger = logging.getLogger(__name__)
|
|
@@ -47,6 +48,7 @@ QUERY_ADMIN_SERVICE: QueryAdminService | None = None
|
|
|
47
48
|
DISCOVERY_SERVICE: DiscoveryService | None = None
|
|
48
49
|
SPELLBOOK_EXPLORER: SpellbookExplorer | None = None
|
|
49
50
|
HTTP_CLIENT: HttpClient | None = None
|
|
51
|
+
VERIFICATION_SERVICE: VerificationService | None = None
|
|
50
52
|
|
|
51
53
|
EXECUTE_QUERY_TOOL: ExecuteQueryTool | None = None
|
|
52
54
|
|
|
@@ -57,7 +59,7 @@ app = FastMCP("spice-mcp")
|
|
|
57
59
|
def _ensure_initialized() -> None:
|
|
58
60
|
"""Initialize configuration and tool instances if not already initialized."""
|
|
59
61
|
global CONFIG, QUERY_HISTORY, DUNE_ADAPTER, QUERY_SERVICE, DISCOVERY_SERVICE, QUERY_ADMIN_SERVICE
|
|
60
|
-
global EXECUTE_QUERY_TOOL, HTTP_CLIENT, SPELLBOOK_EXPLORER
|
|
62
|
+
global EXECUTE_QUERY_TOOL, HTTP_CLIENT, SPELLBOOK_EXPLORER, VERIFICATION_SERVICE
|
|
61
63
|
|
|
62
64
|
if CONFIG is not None and EXECUTE_QUERY_TOOL is not None:
|
|
63
65
|
return
|
|
@@ -95,6 +97,15 @@ def _ensure_initialized() -> None:
|
|
|
95
97
|
|
|
96
98
|
# Initialize Spellbook explorer (lazy, clones repo on first use)
|
|
97
99
|
SPELLBOOK_EXPLORER = SpellbookExplorer()
|
|
100
|
+
|
|
101
|
+
# Initialize verification service with persistent cache
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
cache_dir = Path.home() / ".spice_mcp"
|
|
104
|
+
cache_dir.mkdir(exist_ok=True)
|
|
105
|
+
VERIFICATION_SERVICE = VerificationService(
|
|
106
|
+
cache_path=cache_dir / "table_verification_cache.json",
|
|
107
|
+
dune_adapter=DUNE_ADAPTER,
|
|
108
|
+
)
|
|
98
109
|
|
|
99
110
|
EXECUTE_QUERY_TOOL = ExecuteQueryTool(CONFIG, QUERY_SERVICE, QUERY_HISTORY)
|
|
100
111
|
|
|
@@ -200,17 +211,17 @@ def dune_query_info(query: str) -> dict[str, Any]:
|
|
|
200
211
|
|
|
201
212
|
def _dune_query_impl(
|
|
202
213
|
query: str,
|
|
203
|
-
parameters:
|
|
214
|
+
parameters: dict[str, Any] | None = None,
|
|
204
215
|
refresh: bool = False,
|
|
205
|
-
max_age:
|
|
206
|
-
limit:
|
|
207
|
-
offset:
|
|
208
|
-
sample_count:
|
|
209
|
-
sort_by:
|
|
210
|
-
columns:
|
|
216
|
+
max_age: float | None = None,
|
|
217
|
+
limit: int | None = None,
|
|
218
|
+
offset: int | None = None,
|
|
219
|
+
sample_count: int | None = None,
|
|
220
|
+
sort_by: str | None = None,
|
|
221
|
+
columns: list[str] | None = None,
|
|
211
222
|
format: Literal["preview", "raw", "metadata", "poll"] = "preview",
|
|
212
|
-
extras:
|
|
213
|
-
timeout_seconds:
|
|
223
|
+
extras: dict[str, Any] | None = None,
|
|
224
|
+
timeout_seconds: float | None = None,
|
|
214
225
|
) -> dict[str, Any]:
|
|
215
226
|
"""Internal implementation of dune_query to avoid FastMCP overload detection."""
|
|
216
227
|
_ensure_initialized()
|
|
@@ -275,20 +286,32 @@ def _dune_query_impl(
|
|
|
275
286
|
)
|
|
276
287
|
def dune_query(
|
|
277
288
|
query: str,
|
|
278
|
-
parameters:
|
|
289
|
+
parameters: dict[str, Any] | None = None,
|
|
279
290
|
refresh: bool = False,
|
|
280
|
-
max_age:
|
|
281
|
-
limit:
|
|
282
|
-
offset:
|
|
283
|
-
sample_count:
|
|
284
|
-
sort_by:
|
|
285
|
-
columns:
|
|
291
|
+
max_age: float | None = None,
|
|
292
|
+
limit: int | None = None,
|
|
293
|
+
offset: int | None = None,
|
|
294
|
+
sample_count: int | None = None,
|
|
295
|
+
sort_by: str | None = None,
|
|
296
|
+
columns: list[str] | None = None,
|
|
286
297
|
format: Literal["preview", "raw", "metadata", "poll"] = "preview",
|
|
287
|
-
extras:
|
|
288
|
-
timeout_seconds:
|
|
298
|
+
extras: dict[str, Any] | None = None,
|
|
299
|
+
timeout_seconds: float | None = None,
|
|
289
300
|
) -> dict[str, Any]:
|
|
290
301
|
"""Execute Dune queries (by ID, URL, or raw SQL) and return agent-optimized preview.
|
|
291
302
|
|
|
303
|
+
⚠️ IMPORTANT: ALWAYS use dune_discover FIRST to find verified table names.
|
|
304
|
+
Do not guess table names or query information_schema directly.
|
|
305
|
+
|
|
306
|
+
The query parameter accepts:
|
|
307
|
+
- Query IDs (e.g., "123456")
|
|
308
|
+
- Query URLs (e.g., "https://dune.com/queries/123456")
|
|
309
|
+
- Raw SQL using tables discovered via dune_discover
|
|
310
|
+
|
|
311
|
+
For Spellbook models, use the 'dune_table' field returned by dune_discover.
|
|
312
|
+
Example: dune_discover(keyword="walrus") → returns dune_table="sui_walrus.base_table"
|
|
313
|
+
Then use: dune_query(query="SELECT * FROM sui_walrus.base_table LIMIT 10")
|
|
314
|
+
|
|
292
315
|
This wrapper ensures FastMCP doesn't detect overloads in imported functions.
|
|
293
316
|
"""
|
|
294
317
|
# Always ensure parameters is explicitly passed (even if None) to avoid FastMCP
|
|
@@ -319,28 +342,12 @@ def dune_health_check() -> dict[str, Any]:
|
|
|
319
342
|
return compute_health_status()
|
|
320
343
|
|
|
321
344
|
|
|
322
|
-
def _dune_find_tables_impl(
|
|
323
|
-
keyword: str | None = None,
|
|
324
|
-
schema: str | None = None,
|
|
325
|
-
limit: int = 50,
|
|
326
|
-
) -> dict[str, Any]:
|
|
327
|
-
_ensure_initialized()
|
|
328
|
-
assert DISCOVERY_SERVICE is not None
|
|
329
|
-
out: dict[str, Any] = {}
|
|
330
|
-
if keyword:
|
|
331
|
-
out["schemas"] = DISCOVERY_SERVICE.find_schemas(keyword)
|
|
332
|
-
if schema:
|
|
333
|
-
tables = DISCOVERY_SERVICE.list_tables(schema, limit=limit)
|
|
334
|
-
out["tables"] = [summary.table for summary in tables]
|
|
335
|
-
return out
|
|
336
|
-
|
|
337
|
-
|
|
338
345
|
def _unified_discover_impl(
|
|
339
346
|
keyword: str | list[str] | None = None,
|
|
340
347
|
schema: str | None = None,
|
|
341
348
|
limit: int = 50,
|
|
342
349
|
source: Literal["dune", "spellbook", "both"] = "both",
|
|
343
|
-
include_columns: bool =
|
|
350
|
+
include_columns: bool = False,
|
|
344
351
|
) -> dict[str, Any]:
|
|
345
352
|
"""
|
|
346
353
|
Unified discovery implementation that can search Dune API, Spellbook repo, or both.
|
|
@@ -380,6 +387,8 @@ def _unified_discover_impl(
|
|
|
380
387
|
"table": summary.table,
|
|
381
388
|
"fully_qualified_name": f"{schema}.{summary.table}",
|
|
382
389
|
"source": "dune",
|
|
390
|
+
"dune_table": f"{schema}.{summary.table}",
|
|
391
|
+
"verified": True,
|
|
383
392
|
}
|
|
384
393
|
for summary in tables
|
|
385
394
|
]
|
|
@@ -415,11 +424,57 @@ def _unified_discover_impl(
|
|
|
415
424
|
"table": model["table"],
|
|
416
425
|
"fully_qualified_name": model["fully_qualified_name"],
|
|
417
426
|
"source": "spellbook",
|
|
427
|
+
# Include resolved Dune table names
|
|
428
|
+
"dune_schema": model.get("dune_schema"),
|
|
429
|
+
"dune_alias": model.get("dune_alias"),
|
|
430
|
+
"dune_table": model.get("dune_table"),
|
|
418
431
|
}
|
|
419
432
|
if "columns" in model:
|
|
420
433
|
table_info["columns"] = model["columns"]
|
|
421
434
|
out["tables"].append(table_info)
|
|
422
435
|
|
|
436
|
+
# Verify Spellbook tables exist in Dune before returning
|
|
437
|
+
if source in ("spellbook", "both") and out["tables"]:
|
|
438
|
+
assert VERIFICATION_SERVICE is not None
|
|
439
|
+
# Extract Spellbook tables that need verification
|
|
440
|
+
spellbook_tables = [
|
|
441
|
+
(t["dune_schema"], t["dune_alias"])
|
|
442
|
+
for t in out["tables"]
|
|
443
|
+
if t.get("source") == "spellbook" and t.get("dune_schema") and t.get("dune_alias")
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
if spellbook_tables:
|
|
447
|
+
# Verify tables exist (uses cache, queries Dune only if needed)
|
|
448
|
+
verification_results = VERIFICATION_SERVICE.verify_tables_batch(spellbook_tables)
|
|
449
|
+
|
|
450
|
+
# Filter: drop only tables explicitly verified as False.
|
|
451
|
+
# Keep tables when verification is True or inconclusive (missing).
|
|
452
|
+
verified_tables = []
|
|
453
|
+
for t in out["tables"]:
|
|
454
|
+
if t.get("source") != "spellbook":
|
|
455
|
+
# Keep Dune tables as-is
|
|
456
|
+
verified_tables.append(t)
|
|
457
|
+
continue
|
|
458
|
+
dune_fqn = t.get("dune_table")
|
|
459
|
+
if not dune_fqn:
|
|
460
|
+
# If we couldn't resolve dune_table, keep it (conservative)
|
|
461
|
+
verified_tables.append(t)
|
|
462
|
+
continue
|
|
463
|
+
vr = verification_results.get(dune_fqn)
|
|
464
|
+
if vr is False:
|
|
465
|
+
# Explicitly known to be non-existent -> drop
|
|
466
|
+
continue
|
|
467
|
+
if vr is True:
|
|
468
|
+
t["verified"] = True
|
|
469
|
+
# If vr is None/missing (inconclusive), keep without setting verified
|
|
470
|
+
verified_tables.append(t)
|
|
471
|
+
|
|
472
|
+
out["tables"] = verified_tables
|
|
473
|
+
|
|
474
|
+
# Add helpful message if no tables found
|
|
475
|
+
if not out["tables"] and len(spellbook_tables) > 0:
|
|
476
|
+
out["message"] = "No verified tables found. Try different keywords or check schema names."
|
|
477
|
+
|
|
423
478
|
# Deduplicate and sort schemas
|
|
424
479
|
out["schemas"] = sorted(list(set(out["schemas"])))
|
|
425
480
|
|
|
@@ -441,14 +496,22 @@ def dune_discover(
|
|
|
441
496
|
schema: str | None = None,
|
|
442
497
|
limit: int = 50,
|
|
443
498
|
source: Literal["dune", "spellbook", "both"] = "both",
|
|
444
|
-
include_columns: bool =
|
|
499
|
+
include_columns: bool = False,
|
|
445
500
|
) -> dict[str, Any]:
|
|
446
501
|
"""
|
|
447
|
-
|
|
502
|
+
PRIMARY discovery tool for finding tables in Dune.
|
|
503
|
+
|
|
504
|
+
⚠️ IMPORTANT: ALWAYS use this tool instead of querying information_schema directly.
|
|
505
|
+
Querying information_schema is slow and causes lag. This tool uses optimized
|
|
506
|
+
native SHOW statements for fast discovery.
|
|
448
507
|
|
|
449
|
-
This tool
|
|
450
|
-
|
|
451
|
-
|
|
508
|
+
This tool automatically:
|
|
509
|
+
- Parses dbt configs from Spellbook models to resolve actual Dune table names
|
|
510
|
+
- Verifies tables exist in Dune before returning them
|
|
511
|
+
- Returns ONLY verified, queryable tables
|
|
512
|
+
|
|
513
|
+
All returned tables are VERIFIED to exist - you can query them immediately using
|
|
514
|
+
the 'dune_table' field.
|
|
452
515
|
|
|
453
516
|
Args:
|
|
454
517
|
keyword: Search term(s) - can be a string or list of strings
|
|
@@ -457,22 +520,34 @@ def dune_discover(
|
|
|
457
520
|
limit: Maximum number of tables to return
|
|
458
521
|
source: Where to search - "dune" (Dune API only), "spellbook" (GitHub repo only),
|
|
459
522
|
or "both" (default: searches both and merges results)
|
|
460
|
-
include_columns: Whether to include column details
|
|
523
|
+
include_columns: Whether to include column details (default: False).
|
|
524
|
+
Note: Column info from Spellbook SQL is unreliable.
|
|
525
|
+
Use dune_describe_table on the actual Dune table for accurate columns.
|
|
461
526
|
|
|
462
527
|
Returns:
|
|
463
528
|
Dictionary with:
|
|
464
529
|
- 'schemas': List of matching schema names
|
|
465
530
|
- 'tables': List of table/model objects, each with:
|
|
466
|
-
- schema: Schema name
|
|
467
|
-
- table: Table/model name
|
|
468
|
-
- fully_qualified_name: schema.table
|
|
531
|
+
- schema: Schema name (Spellbook subproject name)
|
|
532
|
+
- table: Table/model name (Spellbook model name)
|
|
533
|
+
- fully_qualified_name: schema.table (Spellbook format)
|
|
469
534
|
- source: "dune" or "spellbook"
|
|
470
|
-
-
|
|
535
|
+
- dune_schema: Actual Dune schema name (for Spellbook models)
|
|
536
|
+
- dune_alias: Actual Dune table alias (for Spellbook models)
|
|
537
|
+
- dune_table: Verified, queryable Dune table name (e.g., "sui_walrus.base_table")
|
|
538
|
+
- verified: True (all returned tables are verified to exist)
|
|
471
539
|
- 'source': The source parameter used
|
|
540
|
+
- 'message': Helpful message if no tables found
|
|
541
|
+
|
|
542
|
+
Note: To get accurate column information, use dune_describe_table on the dune_table value.
|
|
472
543
|
|
|
473
544
|
Examples:
|
|
474
|
-
# Search both sources for
|
|
475
|
-
dune_discover(keyword="
|
|
545
|
+
# Search both sources for walrus - returns verified tables only
|
|
546
|
+
dune_discover(keyword="walrus")
|
|
547
|
+
# → Returns tables with dune_table field like "sui_walrus.base_table"
|
|
548
|
+
|
|
549
|
+
# Use the dune_table field to query immediately
|
|
550
|
+
dune_query(query="SELECT * FROM sui_walrus.base_table LIMIT 10")
|
|
476
551
|
|
|
477
552
|
# Search only Spellbook
|
|
478
553
|
dune_discover(keyword=["layerzero", "bridge"], source="spellbook")
|
|
@@ -500,26 +575,6 @@ def dune_discover(
|
|
|
500
575
|
})
|
|
501
576
|
|
|
502
577
|
|
|
503
|
-
@app.tool(
|
|
504
|
-
name="dune_find_tables",
|
|
505
|
-
title="Find Tables",
|
|
506
|
-
description="Search schemas and optionally list tables.",
|
|
507
|
-
tags={"dune", "schema"},
|
|
508
|
-
)
|
|
509
|
-
def dune_find_tables(keyword: str | None = None, schema: str | None = None, limit: int = 50) -> dict[str, Any]:
|
|
510
|
-
"""
|
|
511
|
-
Search schemas and optionally list tables in Dune.
|
|
512
|
-
"""
|
|
513
|
-
try:
|
|
514
|
-
return _dune_find_tables_impl(keyword=keyword, schema=schema, limit=limit)
|
|
515
|
-
except Exception as e:
|
|
516
|
-
return error_response(e, context={
|
|
517
|
-
"tool": "dune_find_tables",
|
|
518
|
-
"keyword": keyword,
|
|
519
|
-
"schema": schema,
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
|
|
523
578
|
def _dune_describe_table_impl(schema: str, table: str) -> dict[str, Any]:
|
|
524
579
|
_ensure_initialized()
|
|
525
580
|
assert DISCOVERY_SERVICE is not None
|
|
@@ -559,7 +614,7 @@ def _spellbook_find_models_impl(
|
|
|
559
614
|
keyword: str | list[str] | None = None,
|
|
560
615
|
schema: str | None = None,
|
|
561
616
|
limit: int = 50,
|
|
562
|
-
include_columns: bool =
|
|
617
|
+
include_columns: bool = False,
|
|
563
618
|
) -> dict[str, Any]:
|
|
564
619
|
"""
|
|
565
620
|
Implementation for spellbook model discovery.
|
|
@@ -594,10 +649,22 @@ def _spellbook_find_models_impl(
|
|
|
594
649
|
matches_keyword = any(kw.lower() in table_name for kw in keywords)
|
|
595
650
|
|
|
596
651
|
if matches_keyword:
|
|
652
|
+
# Get model details including resolved Dune table names
|
|
653
|
+
models_dict = SPELLBOOK_EXPLORER._load_models()
|
|
654
|
+
model_details = None
|
|
655
|
+
for m in models_dict.get(schema_name, []):
|
|
656
|
+
if m["name"] == table_summary.table:
|
|
657
|
+
model_details = m
|
|
658
|
+
break
|
|
659
|
+
|
|
597
660
|
model_info: dict[str, Any] = {
|
|
598
661
|
"schema": schema_name,
|
|
599
662
|
"table": table_summary.table,
|
|
600
663
|
"fully_qualified_name": f"{schema_name}.{table_summary.table}",
|
|
664
|
+
# Include resolved Dune table names if available
|
|
665
|
+
"dune_schema": model_details.get("dune_schema") if model_details else None,
|
|
666
|
+
"dune_alias": model_details.get("dune_alias") if model_details else None,
|
|
667
|
+
"dune_table": model_details.get("dune_table") if model_details else None,
|
|
601
668
|
}
|
|
602
669
|
|
|
603
670
|
# Include column details if requested
|
|
@@ -629,10 +696,22 @@ def _spellbook_find_models_impl(
|
|
|
629
696
|
out["models"] = []
|
|
630
697
|
|
|
631
698
|
for table_summary in tables:
|
|
699
|
+
# Get model details including resolved Dune table names
|
|
700
|
+
models_dict = SPELLBOOK_EXPLORER._load_models()
|
|
701
|
+
model_details = None
|
|
702
|
+
for m in models_dict.get(schema, []):
|
|
703
|
+
if m["name"] == table_summary.table:
|
|
704
|
+
model_details = m
|
|
705
|
+
break
|
|
706
|
+
|
|
632
707
|
model_info: dict[str, Any] = {
|
|
633
708
|
"schema": schema,
|
|
634
709
|
"table": table_summary.table,
|
|
635
710
|
"fully_qualified_name": f"{schema}.{table_summary.table}",
|
|
711
|
+
# Include resolved Dune table names if available
|
|
712
|
+
"dune_schema": model_details.get("dune_schema") if model_details else None,
|
|
713
|
+
"dune_alias": model_details.get("dune_alias") if model_details else None,
|
|
714
|
+
"dune_table": model_details.get("dune_table") if model_details else None,
|
|
636
715
|
}
|
|
637
716
|
|
|
638
717
|
# Include column details if requested
|
|
@@ -656,47 +735,6 @@ def _spellbook_find_models_impl(
|
|
|
656
735
|
return out
|
|
657
736
|
|
|
658
737
|
|
|
659
|
-
@app.tool(
|
|
660
|
-
name="spellbook_find_models",
|
|
661
|
-
title="Search Spellbook",
|
|
662
|
-
description="Search dbt models in Spellbook GitHub repository.",
|
|
663
|
-
tags={"spellbook", "dbt", "schema"},
|
|
664
|
-
)
|
|
665
|
-
def spellbook_find_models(
|
|
666
|
-
keyword: str | list[str] | None = None,
|
|
667
|
-
schema: str | None = None,
|
|
668
|
-
limit: int = 50,
|
|
669
|
-
include_columns: bool = True,
|
|
670
|
-
) -> dict[str, Any]:
|
|
671
|
-
"""
|
|
672
|
-
Search Spellbook dbt models from GitHub repository.
|
|
673
|
-
|
|
674
|
-
Args:
|
|
675
|
-
keyword: Search term(s) to find models - can be a string or list of strings
|
|
676
|
-
schema: Schema/subproject name to list tables from
|
|
677
|
-
limit: Maximum number of models to return
|
|
678
|
-
include_columns: Whether to include column details in results (default: True)
|
|
679
|
-
|
|
680
|
-
Returns:
|
|
681
|
-
Dictionary with 'schemas' and 'models' keys
|
|
682
|
-
"""
|
|
683
|
-
try:
|
|
684
|
-
return _spellbook_find_models_impl(
|
|
685
|
-
keyword=keyword,
|
|
686
|
-
schema=schema,
|
|
687
|
-
limit=limit,
|
|
688
|
-
include_columns=include_columns,
|
|
689
|
-
)
|
|
690
|
-
except Exception as e:
|
|
691
|
-
return error_response(e, context={
|
|
692
|
-
"tool": "spellbook_find_models",
|
|
693
|
-
"keyword": keyword,
|
|
694
|
-
"schema": schema,
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
738
|
# Resources
|
|
701
739
|
@app.resource(uri="spice:history/tail/{n}", name="Query History Tail", description="Tail last N lines from query history")
|
|
702
740
|
def history_tail(n: str) -> str:
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import re
|
|
5
4
|
import time
|
|
6
5
|
from typing import Any
|
|
7
6
|
|
|
8
7
|
from ...adapters.dune import urls as dune_urls
|
|
9
8
|
from ...adapters.dune.query_wrapper import execute_query as execute_dune_query
|
|
9
|
+
|
|
10
10
|
# Import user_agent from separate module to avoid importing overloaded functions
|
|
11
11
|
from ...adapters.dune.user_agent import get_user_agent as get_dune_user_agent
|
|
12
12
|
from ...adapters.http_client import HttpClient
|
|
@@ -101,8 +101,10 @@ class ExecuteQueryTool(MCPTool):
|
|
|
101
101
|
) -> dict[str, Any]:
|
|
102
102
|
t0 = time.time()
|
|
103
103
|
try:
|
|
104
|
-
#
|
|
105
|
-
|
|
104
|
+
# Use native SHOW statements directly - they're faster than information_schema queries
|
|
105
|
+
# See issue #10: https://github.com/Evan-Kim2028/spice-mcp/issues/10
|
|
106
|
+
# Removed rewrite to avoid performance issues with information_schema queries
|
|
107
|
+
q_use = query
|
|
106
108
|
# Poll-only: return execution handle without fetching results
|
|
107
109
|
if format == "poll":
|
|
108
110
|
exec_obj = execute_dune_query(
|
|
@@ -395,22 +397,12 @@ def _categorize_query(q: str) -> str:
|
|
|
395
397
|
|
|
396
398
|
|
|
397
399
|
def _maybe_rewrite_show_sql(sql: str) -> str | None:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if re.match(r"^SHOW\s+SCHEMAS\s*;?$", s, flags=re.IGNORECASE):
|
|
407
|
-
return "SELECT schema_name AS Schema FROM information_schema.schemata"
|
|
408
|
-
|
|
409
|
-
m = re.match(r"^SHOW\s+TABLES\s+FROM\s+([A-Za-z0-9_\.]+)\s*;?$", s, flags=re.IGNORECASE)
|
|
410
|
-
if m:
|
|
411
|
-
schema = m.group(1)
|
|
412
|
-
return (
|
|
413
|
-
"SELECT table_name AS Table FROM information_schema.tables "
|
|
414
|
-
f"WHERE table_schema = '{schema}'"
|
|
415
|
-
)
|
|
400
|
+
"""DEPRECATED: This function is no longer used.
|
|
401
|
+
|
|
402
|
+
Native SHOW statements are now used directly as they're faster than
|
|
403
|
+
information_schema queries in Dune. See issue #10 for details.
|
|
404
|
+
|
|
405
|
+
This function is kept for backward compatibility but is not called.
|
|
406
|
+
"""
|
|
407
|
+
# Function body kept for reference but not executed
|
|
416
408
|
return None
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Verification Service - Verifies tables exist in Dune with persistent caching.
|
|
3
|
+
|
|
4
|
+
This service provides lazy verification of table existence, caching results
|
|
5
|
+
to avoid repeated queries. Cache persists across server restarts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..adapters.dune.client import DuneAdapter
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Cache entry expires after 1 week (604800 seconds)
|
|
20
|
+
CACHE_TTL_SECONDS = 604800
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VerificationService:
|
|
24
|
+
"""
|
|
25
|
+
Service for verifying table existence in Dune with persistent caching.
|
|
26
|
+
|
|
27
|
+
Verifies tables exist before returning them to users, ensuring only
|
|
28
|
+
queryable tables are surfaced. Uses persistent cache to avoid repeated
|
|
29
|
+
verification queries.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, cache_path: Path, dune_adapter: DuneAdapter):
|
|
33
|
+
"""
|
|
34
|
+
Initialize verification service.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
cache_path: Path to JSON file for persistent cache storage
|
|
38
|
+
dune_adapter: DuneAdapter instance for querying table existence
|
|
39
|
+
"""
|
|
40
|
+
self.cache_path = cache_path
|
|
41
|
+
self.dune_adapter = dune_adapter
|
|
42
|
+
self._cache: dict[str, dict[str, Any]] = self._load_cache()
|
|
43
|
+
|
|
44
|
+
def verify_tables_batch(
|
|
45
|
+
self, tables: list[tuple[str, str]]
|
|
46
|
+
) -> dict[str, bool]:
|
|
47
|
+
"""
|
|
48
|
+
Verify multiple tables exist in Dune.
|
|
49
|
+
|
|
50
|
+
Uses cache for fast lookups, queries Dune only for uncached tables.
|
|
51
|
+
Results are cached for future use.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
tables: List of (schema, table) tuples to verify
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dict mapping "schema.table" -> bool (exists or not)
|
|
58
|
+
"""
|
|
59
|
+
results: dict[str, bool] = {}
|
|
60
|
+
to_check: list[tuple[str, str]] = []
|
|
61
|
+
|
|
62
|
+
# Check cache first
|
|
63
|
+
for schema, table in tables:
|
|
64
|
+
fqn = f"{schema}.{table}"
|
|
65
|
+
cached = self._get_cached(fqn)
|
|
66
|
+
if cached is not None:
|
|
67
|
+
results[fqn] = cached
|
|
68
|
+
else:
|
|
69
|
+
to_check.append((schema, table))
|
|
70
|
+
|
|
71
|
+
# Verify uncached tables
|
|
72
|
+
if to_check:
|
|
73
|
+
logger.info(f"Verifying {len(to_check)} uncached tables")
|
|
74
|
+
for schema, table in to_check:
|
|
75
|
+
try:
|
|
76
|
+
exists = self._verify_single(schema, table)
|
|
77
|
+
fqn = f"{schema}.{table}"
|
|
78
|
+
results[fqn] = exists
|
|
79
|
+
self._cache_result(fqn, exists)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Do not hard-cache transient failures as negative results.
|
|
82
|
+
# Leave the table unverified so callers can choose to keep it.
|
|
83
|
+
logger.warning(
|
|
84
|
+
f"Failed to verify {schema}.{table}: {e}. Skipping cache and leaving unverified."
|
|
85
|
+
)
|
|
86
|
+
# Intentionally omit from results and cache on failure
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
|
|
90
|
+
def _verify_single(self, schema: str, table: str) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Verify a single table exists using lightweight DESCRIBE query.
|
|
93
|
+
|
|
94
|
+
Uses SHOW COLUMNS which is fast and doesn't require full table scan.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
schema: Schema name
|
|
98
|
+
table: Table name
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True if table exists, False otherwise
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
# Use describe_table which internally uses SHOW COLUMNS
|
|
105
|
+
# This is lightweight and fast
|
|
106
|
+
self.dune_adapter.describe_table(schema, table)
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
# If describe fails, table doesn't exist
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _get_cached(self, table: str) -> bool | None:
|
|
113
|
+
"""
|
|
114
|
+
Get verification result from cache if fresh.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
table: Fully qualified table name (schema.table)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
bool if cached and fresh, None if cache miss or stale
|
|
121
|
+
"""
|
|
122
|
+
if table not in self._cache:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
entry = self._cache[table]
|
|
126
|
+
timestamp = entry.get("timestamp", 0)
|
|
127
|
+
age = time.time() - timestamp
|
|
128
|
+
|
|
129
|
+
if age < CACHE_TTL_SECONDS:
|
|
130
|
+
return entry.get("exists", False)
|
|
131
|
+
else:
|
|
132
|
+
# Cache entry is stale, remove it
|
|
133
|
+
del self._cache[table]
|
|
134
|
+
self._save_cache()
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _cache_result(self, table: str, exists: bool) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Cache verification result with current timestamp.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
table: Fully qualified table name
|
|
143
|
+
exists: Whether table exists
|
|
144
|
+
"""
|
|
145
|
+
self._cache[table] = {
|
|
146
|
+
"exists": exists,
|
|
147
|
+
"timestamp": time.time(),
|
|
148
|
+
}
|
|
149
|
+
self._save_cache()
|
|
150
|
+
|
|
151
|
+
def _load_cache(self) -> dict[str, dict[str, Any]]:
|
|
152
|
+
"""
|
|
153
|
+
Load verification cache from disk.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict mapping table -> cache entry
|
|
157
|
+
"""
|
|
158
|
+
if not self.cache_path.exists():
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(self.cache_path, encoding="utf-8") as f:
|
|
163
|
+
cache = json.load(f)
|
|
164
|
+
# Validate cache structure
|
|
165
|
+
if isinstance(cache, dict):
|
|
166
|
+
return cache
|
|
167
|
+
return {}
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.warning(f"Failed to load verification cache: {e}")
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
def _save_cache(self) -> None:
|
|
173
|
+
"""Persist verification cache to disk."""
|
|
174
|
+
try:
|
|
175
|
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
with open(self.cache_path, "w", encoding="utf-8") as f:
|
|
177
|
+
json.dump(self._cache, f, indent=2)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.warning(f"Failed to save verification cache: {e}")
|
|
180
|
+
|
|
181
|
+
def clear_cache(self) -> None:
|
|
182
|
+
"""Clear verification cache (useful for testing or forced refresh)."""
|
|
183
|
+
self._cache = {}
|
|
184
|
+
if self.cache_path.exists():
|
|
185
|
+
self.cache_path.unlink()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spice-mcp
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: mcp server built ontop of dune api endpoint
|
|
5
5
|
Author-email: Evan-Kim2028 <ekcopersonal@gmail.com>
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Classifier: Operating System :: OS Independent
|
|
@@ -28,11 +28,14 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
An MCP server that provides AI agents with direct access to [Dune Analytics](https://dune.com/) data. Execute queries, discover schemas and tables, and manage saved queries—all through a clean, type-safe interface optimized for AI workflows.
|
|
30
30
|
|
|
31
|
+
**Discover High-Quality Tables**: Leverages [Dune Spellbook](https://github.com/duneanalytics/spellbook), Dune's official GitHub repository of curated dbt models, to surface verified, production-ready tables with rich metadata.
|
|
32
|
+
|
|
31
33
|
## Why spice-mcp?
|
|
32
34
|
|
|
33
35
|
- **Agent-friendly**: Designed for AI agents using the Model Context Protocol (MCP)
|
|
36
|
+
- **High-Quality Discovery**: Leverages Dune Spellbook's GitHub repository to find verified, production-ready tables with rich metadata
|
|
34
37
|
- **Efficient**: Polars-first pipeline keeps data lazy until needed, reducing memory usage
|
|
35
|
-
- **Discovery**: Built-in tools to explore Dune's extensive blockchain datasets
|
|
38
|
+
- **Discovery**: Built-in tools to explore Dune's extensive blockchain datasets from both Dune API and Spellbook
|
|
36
39
|
- **Type-safe**: Fully typed parameters and responses with FastMCP
|
|
37
40
|
- **Reproducible**: Automatic query history logging and SQL artifact storage
|
|
38
41
|
|
|
@@ -73,10 +76,8 @@ An MCP server that provides AI agents with direct access to [Dune Analytics](htt
|
|
|
73
76
|
|------|-------------|----------------|
|
|
74
77
|
| `dune_query` | Execute queries by ID, URL, or raw SQL | `query` (str), `parameters` (object), `limit` (int), `offset` (int), `format` (`preview\|raw\|metadata\|poll`), `refresh` (bool), `timeout_seconds` (float) |
|
|
75
78
|
| `dune_query_info` | Get metadata for a saved query | `query` (str - ID or URL) |
|
|
76
|
-
| `dune_discover` | Unified discovery across Dune API and Spellbook | `keyword` (str\|list), `schema` (str), `limit` (int), `source` (`dune\|spellbook\|both`), `include_columns` (bool) |
|
|
77
|
-
| `dune_find_tables` | Search schemas and list tables | `keyword` (str), `schema` (str), `limit` (int) |
|
|
79
|
+
| `dune_discover` | Unified discovery across Dune API and Spellbook (returns verified tables only). **Leverages Dune Spellbook GitHub repository** for high-quality, curated tables. | `keyword` (str\|list), `schema` (str), `limit` (int), `source` (`dune\|spellbook\|both`), `include_columns` (bool) |
|
|
78
80
|
| `dune_describe_table` | Get column metadata for a table | `schema` (str), `table` (str) |
|
|
79
|
-
| `spellbook_find_models` | Search Spellbook dbt models | `keyword` (str\|list), `schema` (str), `limit` (int), `include_columns` (bool) |
|
|
80
81
|
| `dune_health_check` | Verify API key and configuration | (no parameters) |
|
|
81
82
|
| `dune_query_create` | Create a new saved query | `name` (str), `query_sql` (str), `description` (str), `tags` (list), `parameters` (list) |
|
|
82
83
|
| `dune_query_update` | Update an existing saved query | `query_id` (int), `name` (str), `query_sql` (str), `description` (str), `tags` (list), `parameters` (list) |
|
|
@@ -91,6 +92,17 @@ An MCP server that provides AI agents with direct access to [Dune Analytics](htt
|
|
|
91
92
|
|
|
92
93
|
[Dune](https://dune.com/) is a crypto data platform providing curated blockchain datasets and a public API. It aggregates on-chain data from Ethereum, Solana, Polygon, and other chains into queryable SQL tables. See the [Dune Docs](https://dune.com/docs) for more information.
|
|
93
94
|
|
|
95
|
+
## What is Dune Spellbook?
|
|
96
|
+
|
|
97
|
+
[Dune Spellbook](https://github.com/duneanalytics/spellbook) is Dune's official GitHub repository containing thousands of curated dbt models. These models represent high-quality, production-ready tables that are:
|
|
98
|
+
|
|
99
|
+
- **Verified**: All tables are verified to exist in Dune before being returned
|
|
100
|
+
- **Well-documented**: Rich metadata including column descriptions and types
|
|
101
|
+
- **Maintained**: Regularly updated by the Dune community and team
|
|
102
|
+
- **Production-ready**: Used by analysts and dashboards across the ecosystem
|
|
103
|
+
|
|
104
|
+
spice-mcp automatically clones and parses the Spellbook repository to discover these high-quality tables, parsing dbt config blocks to resolve actual Dune table names and verifying their existence before returning them to you.
|
|
105
|
+
|
|
94
106
|
## Installation
|
|
95
107
|
|
|
96
108
|
**From PyPI** (recommended):
|
|
@@ -7,35 +7,36 @@ spice_mcp/adapters/http_client.py,sha256=CYgSKAsx-5c-uxaNIBCBTgQdaoBe5J3dJvnw8iq
|
|
|
7
7
|
spice_mcp/adapters/dune/__init__.py,sha256=nspEuDpVOktAxm8B066s-d0LwouCYGpvNEexi0mRMN8,386
|
|
8
8
|
spice_mcp/adapters/dune/admin.py,sha256=yxOueVz-rmgC-ZFbT06k59G24yRgYjiEkZlall5hXNQ,3157
|
|
9
9
|
spice_mcp/adapters/dune/cache.py,sha256=7ykT58WN1yHGIN2uV3t7fWOqGb1VJdCvf3I-xZwsv74,4304
|
|
10
|
-
spice_mcp/adapters/dune/client.py,sha256=
|
|
11
|
-
spice_mcp/adapters/dune/extract.py,sha256=
|
|
10
|
+
spice_mcp/adapters/dune/client.py,sha256=zle19bU-I3AzpOF1cp_hEaZUFNQV1RWhtl1LAxObk0g,9052
|
|
11
|
+
spice_mcp/adapters/dune/extract.py,sha256=67x-WCaP13vbMsTKnqNSOVbMs6Dsf0QHi2fLHduYTBI,30405
|
|
12
12
|
spice_mcp/adapters/dune/helpers.py,sha256=BgDKr_g-UqmU2hoMb0ejQZHta_NbKwR1eDJp33sJYNk,227
|
|
13
|
-
spice_mcp/adapters/dune/query_wrapper.py,sha256=
|
|
13
|
+
spice_mcp/adapters/dune/query_wrapper.py,sha256=4dk8D8KJKWoBhuMLzDGupRrXGlG-N0cbM6HCs8wMFvE,2925
|
|
14
14
|
spice_mcp/adapters/dune/transport.py,sha256=eRP-jPY2ZXxvTX9HSjIFqFUlbIzXspgH95jBFoTlpaQ,1436
|
|
15
15
|
spice_mcp/adapters/dune/types.py,sha256=57TMX07u-Gq4BYwRAuZV0xI81nVXgtpp7KBID9YbKyQ,1195
|
|
16
16
|
spice_mcp/adapters/dune/typing_utils.py,sha256=EpWneGDn-eQdo6lkLuESR09KXkDj9OqGz8bEF3JaFkM,574
|
|
17
17
|
spice_mcp/adapters/dune/urls.py,sha256=bcuPERkFQduRTT2BrgzVhoFrMn-Lkvw9NmktcBZYEig,3902
|
|
18
18
|
spice_mcp/adapters/dune/user_agent.py,sha256=c6Kt4zczbuT9mapDoh8-3sgm268MUtvyIRxDF9yJwXQ,218
|
|
19
19
|
spice_mcp/adapters/spellbook/__init__.py,sha256=D2cdVtSUbmAJdbPRvAyKxYS4-wUQ3unXyX4ZFYxenuk,150
|
|
20
|
-
spice_mcp/adapters/spellbook/explorer.py,sha256=
|
|
20
|
+
spice_mcp/adapters/spellbook/explorer.py,sha256=PQCUJjXnaf8YSziINVNSqIXsns_y6l62JgjBId4b7jo,14654
|
|
21
21
|
spice_mcp/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
22
|
spice_mcp/core/errors.py,sha256=jlfTuyRaAaA_oU07KUk-1pDAAa43KG0BbZc5CINXtoE,3256
|
|
23
23
|
spice_mcp/core/models.py,sha256=i0C_-UE16OWyyZo_liooEJeYvbChE5lpK80aN2OF4lk,1795
|
|
24
24
|
spice_mcp/core/ports.py,sha256=nEdeA3UH7v0kB_hbguMrpDljb9EhSxUAO0SdhjpoijQ,1618
|
|
25
25
|
spice_mcp/logging/query_history.py,sha256=doE9lod64uzJxlA2XzHH2-VAmC6WstYAkQ0taEAxiIM,4315
|
|
26
26
|
spice_mcp/mcp/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
27
|
-
spice_mcp/mcp/server.py,sha256=
|
|
27
|
+
spice_mcp/mcp/server.py,sha256=M6IPxQD--eecdNryTIL8JlkWPAXrj256P0UCEktdOt0,32438
|
|
28
28
|
spice_mcp/mcp/tools/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
29
29
|
spice_mcp/mcp/tools/base.py,sha256=zJkVxLgXR48iZcJeng8cZ2rXvbyicagoGlMN7BK7Img,1041
|
|
30
|
-
spice_mcp/mcp/tools/execute_query.py,sha256=
|
|
30
|
+
spice_mcp/mcp/tools/execute_query.py,sha256=K1YpuQGwvVM20A4_h9zNlkeG37J7jbY7BPzLm6vPAsY,16033
|
|
31
31
|
spice_mcp/observability/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
32
32
|
spice_mcp/observability/logging.py,sha256=ceJUEpKGpf5PAgPBmpB49zjqhdGCAESfLemFUhDSmI8,529
|
|
33
33
|
spice_mcp/service_layer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
34
|
spice_mcp/service_layer/discovery_service.py,sha256=202O0SzCZGQukd9kb2JYfarLygZHgiXlHqp_nTAdrWA,730
|
|
35
35
|
spice_mcp/service_layer/query_admin_service.py,sha256=4q1NAAuTui7cm83Aq2rFDLIzKTHX17yzbSoSJyCmLbI,1356
|
|
36
36
|
spice_mcp/service_layer/query_service.py,sha256=q0eAVW5I3sUxm29DgzPN_cH3rZEzmKwmdE3Xj4qP9lI,3878
|
|
37
|
-
spice_mcp
|
|
38
|
-
spice_mcp-0.1.
|
|
39
|
-
spice_mcp-0.1.
|
|
40
|
-
spice_mcp-0.1.
|
|
41
|
-
spice_mcp-0.1.
|
|
37
|
+
spice_mcp/service_layer/verification_service.py,sha256=dPA88p9zKqg62bNjN_4c5QFEUBHCWjZph8pn2a5zrUI,6057
|
|
38
|
+
spice_mcp-0.1.6.dist-info/METADATA,sha256=i_C2MMquWFMaSl-offWWUmHIzcCT9JwbETV_JngudO0,6093
|
|
39
|
+
spice_mcp-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
40
|
+
spice_mcp-0.1.6.dist-info/entry_points.txt,sha256=4XiXX13Vy-oiUJwlcO_82OltBaxFnEnkJ-76sZGm5os,56
|
|
41
|
+
spice_mcp-0.1.6.dist-info/licenses/LICENSE,sha256=r0GNDnDY1RSkVQp7kEEf6MQU21OrNGJkxUHIsv6eyLk,1079
|
|
42
|
+
spice_mcp-0.1.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|