spice-mcp 0.1.4__py3-none-any.whl → 0.1.5__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.
@@ -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
- if isinstance(q, str):
51
- q_rewritten = _maybe_rewrite_show_sql(q)
52
- if q_rewritten is not None:
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
- sql_eff = _maybe_rewrite_show_sql(sql) or sql
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
- """Rewrite certain SHOW statements to information_schema SELECTs for portability.
237
-
238
- This allows running discovery-style commands through the parameterized raw SQL
239
- template which expects SELECT statements.
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
- s = sql.strip()
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
@@ -1,9 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import os
4
5
  import time
5
6
  from typing import TYPE_CHECKING, overload
6
- import os
7
7
 
8
8
  from ..http_client import HttpClient
9
9
  from . import cache as _cache
@@ -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,6 +296,58 @@ 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
  """Parse SQL file to extract column names from SELECT statements."""
269
353
  try:
@@ -310,4 +394,3 @@ class SpellbookExplorer(CatalogExplorer):
310
394
  pass
311
395
 
312
396
  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, Optional
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: Optional[dict[str, Any]] = None,
214
+ parameters: dict[str, Any] | None = None,
204
215
  refresh: bool = False,
205
- max_age: Optional[float] = None,
206
- limit: Optional[int] = None,
207
- offset: Optional[int] = None,
208
- sample_count: Optional[int] = None,
209
- sort_by: Optional[str] = None,
210
- columns: Optional[list[str]] = None,
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: Optional[dict[str, Any]] = None,
213
- timeout_seconds: Optional[float] = None,
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: Optional[dict[str, Any]] = None,
289
+ parameters: dict[str, Any] | None = None,
279
290
  refresh: bool = False,
280
- max_age: Optional[float] = None,
281
- limit: Optional[int] = None,
282
- offset: Optional[int] = None,
283
- sample_count: Optional[int] = None,
284
- sort_by: Optional[str] = None,
285
- columns: Optional[list[str]] = None,
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: Optional[dict[str, Any]] = None,
288
- timeout_seconds: Optional[float] = None,
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,22 +342,6 @@ 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,
@@ -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
 
@@ -444,11 +499,19 @@ def dune_discover(
444
499
  include_columns: bool = True,
445
500
  ) -> dict[str, Any]:
446
501
  """
447
- Unified discovery tool for Dune tables and Spellbook models.
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 can search both Dune's live schemas (via SQL queries) and Spellbook's
450
- dbt models (via GitHub repo parsing) in a single call. You don't need to decide
451
- which source to use - it can search both automatically.
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
@@ -463,16 +526,25 @@ def dune_discover(
463
526
  Dictionary with:
464
527
  - 'schemas': List of matching schema names
465
528
  - 'tables': List of table/model objects, each with:
466
- - schema: Schema name
467
- - table: Table/model name
468
- - fully_qualified_name: schema.table
529
+ - schema: Schema name (Spellbook subproject name)
530
+ - table: Table/model name (Spellbook model name)
531
+ - fully_qualified_name: schema.table (Spellbook format)
469
532
  - source: "dune" or "spellbook"
533
+ - dune_schema: Actual Dune schema name (for Spellbook models)
534
+ - dune_alias: Actual Dune table alias (for Spellbook models)
535
+ - dune_table: Verified, queryable Dune table name (e.g., "sui_walrus.base_table")
536
+ - verified: True (all returned tables are verified to exist)
470
537
  - columns: Column details (for Spellbook models, if include_columns=True)
471
538
  - 'source': The source parameter used
539
+ - 'message': Helpful message if no tables found
472
540
 
473
541
  Examples:
474
- # Search both sources for layerzero
475
- dune_discover(keyword="layerzero")
542
+ # Search both sources for walrus - returns verified tables only
543
+ dune_discover(keyword="walrus")
544
+ # → Returns tables with dune_table field like "sui_walrus.base_table"
545
+
546
+ # Use the dune_table field to query immediately
547
+ dune_query(query="SELECT * FROM sui_walrus.base_table LIMIT 10")
476
548
 
477
549
  # Search only Spellbook
478
550
  dune_discover(keyword=["layerzero", "bridge"], source="spellbook")
@@ -500,26 +572,6 @@ def dune_discover(
500
572
  })
501
573
 
502
574
 
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
575
  def _dune_describe_table_impl(schema: str, table: str) -> dict[str, Any]:
524
576
  _ensure_initialized()
525
577
  assert DISCOVERY_SERVICE is not None
@@ -594,10 +646,22 @@ def _spellbook_find_models_impl(
594
646
  matches_keyword = any(kw.lower() in table_name for kw in keywords)
595
647
 
596
648
  if matches_keyword:
649
+ # Get model details including resolved Dune table names
650
+ models_dict = SPELLBOOK_EXPLORER._load_models()
651
+ model_details = None
652
+ for m in models_dict.get(schema_name, []):
653
+ if m["name"] == table_summary.table:
654
+ model_details = m
655
+ break
656
+
597
657
  model_info: dict[str, Any] = {
598
658
  "schema": schema_name,
599
659
  "table": table_summary.table,
600
660
  "fully_qualified_name": f"{schema_name}.{table_summary.table}",
661
+ # Include resolved Dune table names if available
662
+ "dune_schema": model_details.get("dune_schema") if model_details else None,
663
+ "dune_alias": model_details.get("dune_alias") if model_details else None,
664
+ "dune_table": model_details.get("dune_table") if model_details else None,
601
665
  }
602
666
 
603
667
  # Include column details if requested
@@ -629,10 +693,22 @@ def _spellbook_find_models_impl(
629
693
  out["models"] = []
630
694
 
631
695
  for table_summary in tables:
696
+ # Get model details including resolved Dune table names
697
+ models_dict = SPELLBOOK_EXPLORER._load_models()
698
+ model_details = None
699
+ for m in models_dict.get(schema, []):
700
+ if m["name"] == table_summary.table:
701
+ model_details = m
702
+ break
703
+
632
704
  model_info: dict[str, Any] = {
633
705
  "schema": schema,
634
706
  "table": table_summary.table,
635
707
  "fully_qualified_name": f"{schema}.{table_summary.table}",
708
+ # Include resolved Dune table names if available
709
+ "dune_schema": model_details.get("dune_schema") if model_details else None,
710
+ "dune_alias": model_details.get("dune_alias") if model_details else None,
711
+ "dune_table": model_details.get("dune_table") if model_details else None,
636
712
  }
637
713
 
638
714
  # Include column details if requested
@@ -656,47 +732,6 @@ def _spellbook_find_models_impl(
656
732
  return out
657
733
 
658
734
 
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
735
  # Resources
701
736
  @app.resource(uri="spice:history/tail/{n}", name="Query History Tail", description="Tail last N lines from query history")
702
737
  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
- # Rewrite SHOW statements to portable information_schema SELECTs
105
- q_use = _maybe_rewrite_show_sql(query) or query
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
- s = sql.strip()
399
- m = re.match(r"^SHOW\s+SCHEMAS\s+LIKE\s+'([^']+)'\s*;?$", s, flags=re.IGNORECASE)
400
- if m:
401
- pat = m.group(1)
402
- return (
403
- "SELECT schema_name AS Schema FROM information_schema.schemata "
404
- f"WHERE schema_name LIKE '{pat}'"
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
4
- Summary: MCP server for Dune Analytics data access
3
+ Version: 0.1.5
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=4-Ay2FQf_vo-eB6I9Kul3f1PgS78PPuUJ7zldebOtKU,9424
11
- spice_mcp/adapters/dune/extract.py,sha256=30D-5NyiOXDtMZoo1dU9pf5yAAFR_ALn6JWvhipPRG0,30405
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=Km64otc00u9ieUhpZmL2aNYb9ETt6PoNb8czShIuPbY,2925
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=Q3UfEGlALizCDeeW_ZZVRRedzwMXiknmxwSkeOnxxgc,11515
20
+ spice_mcp/adapters/spellbook/explorer.py,sha256=nfecYztzxfBXp9b9IKcP4dVICCnPzhHvPfNJKFhaKxA,14856
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=lHYEI76oQpPcpPT9pEoGXpUmAjDFXlOXuUrlOWV8s2c,28729
27
+ spice_mcp/mcp/server.py,sha256=oi0RRaSithCgUt0Qt6Tb3gks32LN0dOOwN7kX-BrN7s,32263
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=CJtoNKpRY6pCkyqjwFy_cTYqTIg9-EsZA8GrH-2MFjk,16262
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-0.1.4.dist-info/METADATA,sha256=MyGS87Cwkx0G5urib8a2JbqZVP4stYdGeVknRxzPw5A,5053
38
- spice_mcp-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
- spice_mcp-0.1.4.dist-info/entry_points.txt,sha256=4XiXX13Vy-oiUJwlcO_82OltBaxFnEnkJ-76sZGm5os,56
40
- spice_mcp-0.1.4.dist-info/licenses/LICENSE,sha256=r0GNDnDY1RSkVQp7kEEf6MQU21OrNGJkxUHIsv6eyLk,1079
41
- spice_mcp-0.1.4.dist-info/RECORD,,
37
+ spice_mcp/service_layer/verification_service.py,sha256=dPA88p9zKqg62bNjN_4c5QFEUBHCWjZph8pn2a5zrUI,6057
38
+ spice_mcp-0.1.5.dist-info/METADATA,sha256=ag4hjEMz-qxvImxJPxpsQN_0F9BOUdbk6TF8zAXW5I4,6093
39
+ spice_mcp-0.1.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
40
+ spice_mcp-0.1.5.dist-info/entry_points.txt,sha256=4XiXX13Vy-oiUJwlcO_82OltBaxFnEnkJ-76sZGm5os,56
41
+ spice_mcp-0.1.5.dist-info/licenses/LICENSE,sha256=r0GNDnDY1RSkVQp7kEEf6MQU21OrNGJkxUHIsv6eyLk,1079
42
+ spice_mcp-0.1.5.dist-info/RECORD,,