spice-mcp 0.1.3__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.
- spice_mcp/adapters/dune/client.py +22 -33
- spice_mcp/adapters/dune/extract.py +49 -5
- spice_mcp/adapters/dune/query_wrapper.py +86 -0
- spice_mcp/adapters/dune/user_agent.py +9 -0
- spice_mcp/adapters/spellbook/explorer.py +84 -1
- spice_mcp/mcp/server.py +199 -99
- spice_mcp/mcp/tools/execute_query.py +19 -25
- spice_mcp/service_layer/verification_service.py +185 -0
- spice_mcp-0.1.5.dist-info/METADATA +133 -0
- {spice_mcp-0.1.3.dist-info → spice_mcp-0.1.5.dist-info}/RECORD +13 -10
- spice_mcp-0.1.3.dist-info/METADATA +0 -198
- {spice_mcp-0.1.3.dist-info → spice_mcp-0.1.5.dist-info}/WHEEL +0 -0
- {spice_mcp-0.1.3.dist-info → spice_mcp-0.1.5.dist-info}/entry_points.txt +0 -0
- {spice_mcp-0.1.3.dist-info → spice_mcp-0.1.5.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
|
|
@@ -24,6 +23,10 @@ from ...polars_utils import collect_preview
|
|
|
24
23
|
from ..http_client import HttpClient, HttpClientConfig
|
|
25
24
|
from . import extract, urls
|
|
26
25
|
|
|
26
|
+
# Use wrapper to avoid FastMCP detecting overloads in extract.query()
|
|
27
|
+
# Note: We still import extract for _determine_input_type and other non-overloaded functions
|
|
28
|
+
from .query_wrapper import execute_query as _execute_dune_query
|
|
29
|
+
|
|
27
30
|
|
|
28
31
|
class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
29
32
|
"""Thin façade around the vendored extract module."""
|
|
@@ -43,11 +46,10 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
|
43
46
|
self._ensure_api_key()
|
|
44
47
|
start = time.time()
|
|
45
48
|
q = request.query
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
result = extract.query(
|
|
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
|
|
52
|
+
result = _execute_dune_query(
|
|
51
53
|
query_or_execution=q,
|
|
52
54
|
verbose=False,
|
|
53
55
|
refresh=request.refresh,
|
|
@@ -123,9 +125,10 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
|
123
125
|
pass
|
|
124
126
|
|
|
125
127
|
url = urls.get_query_results_url(query_id, parameters=params, csv=False)
|
|
128
|
+
from .user_agent import get_user_agent
|
|
126
129
|
headers = {
|
|
127
130
|
"X-Dune-API-Key": self._api_key(),
|
|
128
|
-
"User-Agent":
|
|
131
|
+
"User-Agent": get_user_agent(),
|
|
129
132
|
}
|
|
130
133
|
try:
|
|
131
134
|
resp = self._http.request("GET", url, headers=headers)
|
|
@@ -195,9 +198,11 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
|
|
|
195
198
|
# Internal helpers --------------------------------------------------------------
|
|
196
199
|
def _run_sql(self, sql: str, *, limit: int | None = None) -> pl.DataFrame:
|
|
197
200
|
self._ensure_api_key()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
df = _execute_dune_query(
|
|
205
|
+
query_or_execution=sql_eff,
|
|
201
206
|
verbose=False,
|
|
202
207
|
performance="medium",
|
|
203
208
|
timeout_seconds=self.config.default_timeout_seconds,
|
|
@@ -228,28 +233,12 @@ def _build_preview(lf: pl.LazyFrame, columns: list[str], rowcount: int) -> Resul
|
|
|
228
233
|
|
|
229
234
|
|
|
230
235
|
def _maybe_rewrite_show_sql(sql: str) -> str | None:
|
|
231
|
-
"""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
235
242
|
"""
|
|
236
|
-
|
|
237
|
-
m = re.match(r"^SHOW\s+SCHEMAS\s+LIKE\s+'([^']+)'\s*;?$", s, flags=re.IGNORECASE)
|
|
238
|
-
if m:
|
|
239
|
-
pat = m.group(1)
|
|
240
|
-
return (
|
|
241
|
-
"SELECT schema_name AS Schema FROM information_schema.schemata "
|
|
242
|
-
f"WHERE schema_name LIKE '{pat}'"
|
|
243
|
-
)
|
|
244
|
-
if re.match(r"^SHOW\s+SCHEMAS\s*;?$", s, flags=re.IGNORECASE):
|
|
245
|
-
return "SELECT schema_name AS Schema FROM information_schema.schemata"
|
|
246
|
-
|
|
247
|
-
m = re.match(r"^SHOW\s+TABLES\s+FROM\s+([A-Za-z0-9_\.]+)\s*;?$", s, flags=re.IGNORECASE)
|
|
248
|
-
if m:
|
|
249
|
-
schema = m.group(1)
|
|
250
|
-
return (
|
|
251
|
-
"SELECT table_name AS Table FROM information_schema.tables "
|
|
252
|
-
f"WHERE table_schema = '{schema}'"
|
|
253
|
-
)
|
|
254
|
-
|
|
243
|
+
# Function body kept for reference but not executed
|
|
255
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
|
|
@@ -289,6 +289,14 @@ def query(
|
|
|
289
289
|
|
|
290
290
|
# execute or retrieve query
|
|
291
291
|
if query_id:
|
|
292
|
+
# Check if this is a parameterized query (raw SQL via template or parameterized query)
|
|
293
|
+
# For parameterized queries, results don't exist until execution, so skip GET attempt
|
|
294
|
+
is_parameterized = (
|
|
295
|
+
parameters is not None
|
|
296
|
+
and len(parameters) > 0
|
|
297
|
+
and ('query' in parameters or any(k != 'query' for k in parameters))
|
|
298
|
+
)
|
|
299
|
+
|
|
292
300
|
if cache and load_from_cache and not refresh:
|
|
293
301
|
cache_result, cache_execution = _cache.load_from_cache(
|
|
294
302
|
execute_kwargs, result_kwargs, output_kwargs
|
|
@@ -301,7 +309,8 @@ def query(
|
|
|
301
309
|
age = get_query_latest_age(**execute_kwargs, verbose=verbose) # type: ignore
|
|
302
310
|
if age is None or age > max_age:
|
|
303
311
|
refresh = True
|
|
304
|
-
|
|
312
|
+
# Skip GET results attempt for parameterized queries - they need execution first
|
|
313
|
+
if not refresh and not is_parameterized:
|
|
305
314
|
df = get_results(**execute_kwargs, **result_kwargs)
|
|
306
315
|
if df is not None:
|
|
307
316
|
return process_result(df, execution, **output_kwargs)
|
|
@@ -334,9 +343,44 @@ def query(
|
|
|
334
343
|
return execution
|
|
335
344
|
|
|
336
345
|
|
|
337
|
-
|
|
338
|
-
@overload
|
|
339
|
-
|
|
346
|
+
if TYPE_CHECKING:
|
|
347
|
+
@overload
|
|
348
|
+
def _process_result(
|
|
349
|
+
df: pl.DataFrame,
|
|
350
|
+
execution: Execution | None,
|
|
351
|
+
execute_kwargs: ExecuteKwargs,
|
|
352
|
+
result_kwargs: RetrievalKwargs,
|
|
353
|
+
cache: bool,
|
|
354
|
+
save_to_cache: bool,
|
|
355
|
+
cache_dir: str | None,
|
|
356
|
+
include_execution: Literal[False],
|
|
357
|
+
) -> pl.DataFrame: ...
|
|
358
|
+
|
|
359
|
+
@overload
|
|
360
|
+
def _process_result(
|
|
361
|
+
df: pl.DataFrame,
|
|
362
|
+
execution: Execution | None,
|
|
363
|
+
execute_kwargs: ExecuteKwargs,
|
|
364
|
+
result_kwargs: RetrievalKwargs,
|
|
365
|
+
cache: bool,
|
|
366
|
+
save_to_cache: bool,
|
|
367
|
+
cache_dir: str | None,
|
|
368
|
+
include_execution: Literal[True],
|
|
369
|
+
) -> tuple[pl.DataFrame, Execution]: ...
|
|
370
|
+
|
|
371
|
+
@overload
|
|
372
|
+
def _process_result(
|
|
373
|
+
df: pl.DataFrame,
|
|
374
|
+
execution: Execution | None,
|
|
375
|
+
execute_kwargs: ExecuteKwargs,
|
|
376
|
+
result_kwargs: RetrievalKwargs,
|
|
377
|
+
cache: bool,
|
|
378
|
+
save_to_cache: bool,
|
|
379
|
+
cache_dir: str | None,
|
|
380
|
+
include_execution: bool,
|
|
381
|
+
) -> pl.DataFrame | tuple[pl.DataFrame, Execution]: ...
|
|
382
|
+
|
|
383
|
+
|
|
340
384
|
def _process_result(
|
|
341
385
|
df: pl.DataFrame,
|
|
342
386
|
execution: Execution | None,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Wrapper for extract.query() to avoid FastMCP overload detection.
|
|
2
|
+
|
|
3
|
+
This module provides a clean interface to extract.query() without exposing
|
|
4
|
+
the @overload decorators that FastMCP detects during runtime validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..http_client import HttpClient
|
|
13
|
+
from .types import Execution, Performance, Query
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def execute_query(
|
|
17
|
+
query_or_execution: Query | Execution,
|
|
18
|
+
*,
|
|
19
|
+
verbose: bool = True,
|
|
20
|
+
refresh: bool = False,
|
|
21
|
+
max_age: float | None = None,
|
|
22
|
+
parameters: Mapping[str, Any] | None = None,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
performance: Performance = "medium",
|
|
25
|
+
poll: bool = True,
|
|
26
|
+
poll_interval: float = 1.0,
|
|
27
|
+
timeout_seconds: float | None = None,
|
|
28
|
+
limit: int | None = None,
|
|
29
|
+
offset: int | None = None,
|
|
30
|
+
sample_count: int | None = None,
|
|
31
|
+
sort_by: str | None = None,
|
|
32
|
+
columns: Sequence[str] | None = None,
|
|
33
|
+
extras: Mapping[str, Any] | None = None,
|
|
34
|
+
types: Sequence[type] | Mapping[str, type] | None = None,
|
|
35
|
+
all_types: Sequence[type] | Mapping[str, type] | None = None,
|
|
36
|
+
cache: bool = True,
|
|
37
|
+
cache_dir: str | None = None,
|
|
38
|
+
save_to_cache: bool = True,
|
|
39
|
+
load_from_cache: bool = True,
|
|
40
|
+
include_execution: bool = False,
|
|
41
|
+
http_client: HttpClient | None = None,
|
|
42
|
+
) -> Any:
|
|
43
|
+
"""
|
|
44
|
+
Execute a Dune query without exposing overloads to FastMCP.
|
|
45
|
+
|
|
46
|
+
This is a wrapper around extract.query() that has a single, non-overloaded
|
|
47
|
+
signature. FastMCP won't detect overloads when inspecting this function.
|
|
48
|
+
"""
|
|
49
|
+
# Import here to avoid FastMCP seeing overloads during module import
|
|
50
|
+
from . import extract
|
|
51
|
+
|
|
52
|
+
# Call the actual query function - FastMCP won't trace through this wrapper
|
|
53
|
+
try:
|
|
54
|
+
return extract.query(
|
|
55
|
+
query_or_execution=query_or_execution,
|
|
56
|
+
verbose=verbose,
|
|
57
|
+
refresh=refresh,
|
|
58
|
+
max_age=max_age,
|
|
59
|
+
parameters=parameters,
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
performance=performance,
|
|
62
|
+
poll=poll,
|
|
63
|
+
poll_interval=poll_interval,
|
|
64
|
+
timeout_seconds=timeout_seconds,
|
|
65
|
+
limit=limit,
|
|
66
|
+
offset=offset,
|
|
67
|
+
sample_count=sample_count,
|
|
68
|
+
sort_by=sort_by,
|
|
69
|
+
columns=columns,
|
|
70
|
+
extras=extras,
|
|
71
|
+
types=types,
|
|
72
|
+
all_types=all_types,
|
|
73
|
+
cache=cache,
|
|
74
|
+
cache_dir=cache_dir,
|
|
75
|
+
save_to_cache=save_to_cache,
|
|
76
|
+
load_from_cache=load_from_cache,
|
|
77
|
+
include_execution=include_execution,
|
|
78
|
+
http_client=http_client,
|
|
79
|
+
)
|
|
80
|
+
except NotImplementedError as exc:
|
|
81
|
+
# Provide additional context to help diagnose overload issues
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
"Underlying extract.query() raised NotImplementedError. "
|
|
84
|
+
"This suggests we're calling an overloaded stub."
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
@@ -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
|
-
|