spice-mcp 0.1.2__py3-none-any.whl → 0.1.4__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/__init__.py +4 -2
- spice_mcp/adapters/dune/cache.py +2 -34
- spice_mcp/adapters/dune/client.py +9 -4
- spice_mcp/adapters/dune/extract.py +79 -633
- spice_mcp/adapters/dune/query_wrapper.py +86 -0
- spice_mcp/adapters/dune/user_agent.py +9 -0
- spice_mcp/adapters/spellbook/__init__.py +6 -0
- spice_mcp/adapters/spellbook/explorer.py +313 -0
- spice_mcp/config.py +1 -1
- spice_mcp/core/models.py +0 -8
- spice_mcp/core/ports.py +0 -15
- spice_mcp/mcp/server.py +407 -137
- spice_mcp/mcp/tools/base.py +1 -1
- spice_mcp/mcp/tools/execute_query.py +32 -63
- spice_mcp-0.1.4.dist-info/METADATA +121 -0
- {spice_mcp-0.1.2.dist-info → spice_mcp-0.1.4.dist-info}/RECORD +19 -17
- spice_mcp/mcp/tools/sui_package_overview.py +0 -56
- spice_mcp/service_layer/sui_service.py +0 -131
- spice_mcp-0.1.2.dist-info/METADATA +0 -193
- {spice_mcp-0.1.2.dist-info → spice_mcp-0.1.4.dist-info}/WHEEL +0 -0
- {spice_mcp-0.1.2.dist-info → spice_mcp-0.1.4.dist-info}/entry_points.txt +0 -0
- {spice_mcp-0.1.2.dist-info → spice_mcp-0.1.4.dist-info}/licenses/LICENSE +0 -0
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, Optional
|
|
6
6
|
|
|
7
7
|
os.environ.setdefault("FASTMCP_NO_BANNER", "1")
|
|
8
8
|
os.environ.setdefault("FASTMCP_LOG_LEVEL", "ERROR")
|
|
@@ -22,20 +22,18 @@ try:
|
|
|
22
22
|
except Exception:
|
|
23
23
|
pass
|
|
24
24
|
|
|
25
|
-
from ..adapters.dune import extract as dune_extract
|
|
26
25
|
from ..adapters.dune import urls as dune_urls
|
|
27
26
|
from ..adapters.dune.admin import DuneAdminAdapter
|
|
28
27
|
from ..adapters.dune.client import DuneAdapter
|
|
29
28
|
from ..adapters.http_client import HttpClient
|
|
29
|
+
from ..adapters.spellbook.explorer import SpellbookExplorer
|
|
30
30
|
from ..config import Config
|
|
31
31
|
from ..core.errors import error_response
|
|
32
32
|
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.sui_service import SuiService
|
|
37
36
|
from .tools.execute_query import ExecuteQueryTool
|
|
38
|
-
from .tools.sui_package_overview import SuiPackageOverviewTool
|
|
39
37
|
|
|
40
38
|
logger = logging.getLogger(__name__)
|
|
41
39
|
|
|
@@ -46,13 +44,11 @@ QUERY_HISTORY: QueryHistory | None = None
|
|
|
46
44
|
DUNE_ADAPTER: DuneAdapter | None = None
|
|
47
45
|
QUERY_SERVICE: QueryService | None = None
|
|
48
46
|
QUERY_ADMIN_SERVICE: QueryAdminService | None = None
|
|
49
|
-
SEMAPHORE = None
|
|
50
47
|
DISCOVERY_SERVICE: DiscoveryService | None = None
|
|
51
|
-
|
|
48
|
+
SPELLBOOK_EXPLORER: SpellbookExplorer | None = None
|
|
52
49
|
HTTP_CLIENT: HttpClient | None = None
|
|
53
50
|
|
|
54
51
|
EXECUTE_QUERY_TOOL: ExecuteQueryTool | None = None
|
|
55
|
-
SUI_OVERVIEW_TOOL: SuiPackageOverviewTool | None = None
|
|
56
52
|
|
|
57
53
|
|
|
58
54
|
app = FastMCP("spice-mcp")
|
|
@@ -60,8 +56,8 @@ app = FastMCP("spice-mcp")
|
|
|
60
56
|
|
|
61
57
|
def _ensure_initialized() -> None:
|
|
62
58
|
"""Initialize configuration and tool instances if not already initialized."""
|
|
63
|
-
global CONFIG, QUERY_HISTORY, DUNE_ADAPTER, QUERY_SERVICE, DISCOVERY_SERVICE,
|
|
64
|
-
global EXECUTE_QUERY_TOOL,
|
|
59
|
+
global CONFIG, QUERY_HISTORY, DUNE_ADAPTER, QUERY_SERVICE, DISCOVERY_SERVICE, QUERY_ADMIN_SERVICE
|
|
60
|
+
global EXECUTE_QUERY_TOOL, HTTP_CLIENT, SPELLBOOK_EXPLORER
|
|
65
61
|
|
|
66
62
|
if CONFIG is not None and EXECUTE_QUERY_TOOL is not None:
|
|
67
63
|
return
|
|
@@ -96,17 +92,11 @@ def _ensure_initialized() -> None:
|
|
|
96
92
|
http_config=CONFIG.http,
|
|
97
93
|
)
|
|
98
94
|
)
|
|
99
|
-
|
|
95
|
+
|
|
96
|
+
# Initialize Spellbook explorer (lazy, clones repo on first use)
|
|
97
|
+
SPELLBOOK_EXPLORER = SpellbookExplorer()
|
|
100
98
|
|
|
101
99
|
EXECUTE_QUERY_TOOL = ExecuteQueryTool(CONFIG, QUERY_SERVICE, QUERY_HISTORY)
|
|
102
|
-
SUI_OVERVIEW_TOOL = SuiPackageOverviewTool(SUI_SERVICE)
|
|
103
|
-
# Concurrency gate for heavy query executions
|
|
104
|
-
try:
|
|
105
|
-
import asyncio
|
|
106
|
-
global SEMAPHORE
|
|
107
|
-
SEMAPHORE = asyncio.Semaphore(CONFIG.max_concurrent_queries)
|
|
108
|
-
except Exception:
|
|
109
|
-
pass
|
|
110
100
|
|
|
111
101
|
logger.info("spice-mcp server ready (fastmcp)!")
|
|
112
102
|
|
|
@@ -153,9 +143,10 @@ def compute_health_status() -> dict[str, Any]:
|
|
|
153
143
|
if tmpl:
|
|
154
144
|
tid = dune_urls.get_query_id(tmpl)
|
|
155
145
|
url = dune_urls.url_templates["query"].format(query_id=tid)
|
|
146
|
+
from ..adapters.dune.user_agent import get_user_agent as get_dune_user_agent
|
|
156
147
|
headers = {
|
|
157
148
|
"X-Dune-API-Key": os.getenv("DUNE_API_KEY", ""),
|
|
158
|
-
"User-Agent":
|
|
149
|
+
"User-Agent": get_dune_user_agent(),
|
|
159
150
|
}
|
|
160
151
|
client = HTTP_CLIENT or HttpClient(Config.from_env().http)
|
|
161
152
|
resp = client.request("GET", url, headers=headers, timeout=5.0)
|
|
@@ -173,14 +164,15 @@ def compute_health_status() -> dict[str, Any]:
|
|
|
173
164
|
description="Fetch Dune query metadata (name, parameters, tags, SQL).",
|
|
174
165
|
tags={"dune", "query"},
|
|
175
166
|
)
|
|
176
|
-
|
|
167
|
+
def dune_query_info(query: str) -> dict[str, Any]:
|
|
177
168
|
_ensure_initialized()
|
|
178
169
|
try:
|
|
179
170
|
qid = dune_urls.get_query_id(query)
|
|
180
171
|
url = dune_urls.url_templates["query"].format(query_id=qid)
|
|
172
|
+
from ..adapters.dune.user_agent import get_user_agent as get_dune_user_agent
|
|
181
173
|
headers = {
|
|
182
174
|
"X-Dune-API-Key": dune_urls.get_api_key(),
|
|
183
|
-
"User-Agent":
|
|
175
|
+
"User-Agent": get_dune_user_agent(),
|
|
184
176
|
}
|
|
185
177
|
client = HTTP_CLIENT or HttpClient(Config.from_env().http)
|
|
186
178
|
resp = client.request("GET", url, headers=headers, timeout=10.0)
|
|
@@ -206,33 +198,55 @@ async def dune_query_info(query: str) -> dict[str, Any]:
|
|
|
206
198
|
})
|
|
207
199
|
|
|
208
200
|
|
|
209
|
-
|
|
210
|
-
name="dune_query",
|
|
211
|
-
title="Run Dune Query",
|
|
212
|
-
description="Execute Dune queries and return agent-optimized preview.",
|
|
213
|
-
tags={"dune", "query"},
|
|
214
|
-
)
|
|
215
|
-
async def dune_query(
|
|
201
|
+
def _dune_query_impl(
|
|
216
202
|
query: str,
|
|
217
|
-
parameters: dict[str, Any]
|
|
203
|
+
parameters: Optional[dict[str, Any]] = None,
|
|
218
204
|
refresh: bool = False,
|
|
219
|
-
max_age: float
|
|
220
|
-
limit: int
|
|
221
|
-
offset: int
|
|
222
|
-
sample_count: int
|
|
223
|
-
sort_by: str
|
|
224
|
-
columns: list[str]
|
|
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,
|
|
225
211
|
format: Literal["preview", "raw", "metadata", "poll"] = "preview",
|
|
226
|
-
extras: dict[str, Any]
|
|
227
|
-
timeout_seconds: float
|
|
212
|
+
extras: Optional[dict[str, Any]] = None,
|
|
213
|
+
timeout_seconds: Optional[float] = None,
|
|
228
214
|
) -> dict[str, Any]:
|
|
215
|
+
"""Internal implementation of dune_query to avoid FastMCP overload detection."""
|
|
229
216
|
_ensure_initialized()
|
|
230
217
|
assert EXECUTE_QUERY_TOOL is not None
|
|
218
|
+
|
|
219
|
+
# Normalize parameters: handle case where MCP client passes JSON string
|
|
220
|
+
# This can happen if FastMCP's schema generation doesn't match client expectations
|
|
221
|
+
normalized_parameters = parameters
|
|
222
|
+
if isinstance(parameters, str):
|
|
223
|
+
try:
|
|
224
|
+
import json
|
|
225
|
+
normalized_parameters = json.loads(parameters)
|
|
226
|
+
except (json.JSONDecodeError, TypeError):
|
|
227
|
+
return error_response(
|
|
228
|
+
ValueError(f"parameters must be a dict or JSON string, got {type(parameters).__name__}"),
|
|
229
|
+
context={
|
|
230
|
+
"tool": "dune_query",
|
|
231
|
+
"query": query,
|
|
232
|
+
"parameters_type": type(parameters).__name__,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Normalize extras similarly
|
|
237
|
+
normalized_extras = extras
|
|
238
|
+
if isinstance(extras, str):
|
|
239
|
+
try:
|
|
240
|
+
import json
|
|
241
|
+
normalized_extras = json.loads(extras)
|
|
242
|
+
except (json.JSONDecodeError, TypeError):
|
|
243
|
+
normalized_extras = None
|
|
244
|
+
|
|
231
245
|
try:
|
|
232
|
-
# Execute query
|
|
233
|
-
return
|
|
246
|
+
# Execute query synchronously
|
|
247
|
+
return EXECUTE_QUERY_TOOL.execute(
|
|
234
248
|
query=query,
|
|
235
|
-
parameters=
|
|
249
|
+
parameters=normalized_parameters,
|
|
236
250
|
refresh=refresh,
|
|
237
251
|
max_age=max_age,
|
|
238
252
|
limit=limit,
|
|
@@ -241,7 +255,7 @@ async def dune_query(
|
|
|
241
255
|
sort_by=sort_by,
|
|
242
256
|
columns=columns,
|
|
243
257
|
format=format,
|
|
244
|
-
extras=
|
|
258
|
+
extras=normalized_extras,
|
|
245
259
|
timeout_seconds=timeout_seconds,
|
|
246
260
|
)
|
|
247
261
|
except Exception as e:
|
|
@@ -253,17 +267,59 @@ async def dune_query(
|
|
|
253
267
|
})
|
|
254
268
|
|
|
255
269
|
|
|
270
|
+
@app.tool(
|
|
271
|
+
name="dune_query",
|
|
272
|
+
title="Run Dune Query",
|
|
273
|
+
description="Execute Dune queries and return agent-optimized preview.",
|
|
274
|
+
tags={"dune", "query"},
|
|
275
|
+
)
|
|
276
|
+
def dune_query(
|
|
277
|
+
query: str,
|
|
278
|
+
parameters: Optional[dict[str, Any]] = None,
|
|
279
|
+
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,
|
|
286
|
+
format: Literal["preview", "raw", "metadata", "poll"] = "preview",
|
|
287
|
+
extras: Optional[dict[str, Any]] = None,
|
|
288
|
+
timeout_seconds: Optional[float] = None,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
"""Execute Dune queries (by ID, URL, or raw SQL) and return agent-optimized preview.
|
|
291
|
+
|
|
292
|
+
This wrapper ensures FastMCP doesn't detect overloads in imported functions.
|
|
293
|
+
"""
|
|
294
|
+
# Always ensure parameters is explicitly passed (even if None) to avoid FastMCP
|
|
295
|
+
# overload detection when the keyword is omitted
|
|
296
|
+
return _dune_query_impl(
|
|
297
|
+
query=query,
|
|
298
|
+
parameters=parameters,
|
|
299
|
+
refresh=refresh,
|
|
300
|
+
max_age=max_age,
|
|
301
|
+
limit=limit,
|
|
302
|
+
offset=offset,
|
|
303
|
+
sample_count=sample_count,
|
|
304
|
+
sort_by=sort_by,
|
|
305
|
+
columns=columns,
|
|
306
|
+
format=format,
|
|
307
|
+
extras=extras,
|
|
308
|
+
timeout_seconds=timeout_seconds,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
256
312
|
@app.tool(
|
|
257
313
|
name="dune_health_check",
|
|
258
314
|
title="Health Check",
|
|
259
315
|
description="Validate Dune API key presence and logging setup.",
|
|
260
316
|
tags={"health"},
|
|
261
317
|
)
|
|
262
|
-
|
|
318
|
+
def dune_health_check() -> dict[str, Any]:
|
|
263
319
|
return compute_health_status()
|
|
264
320
|
|
|
265
321
|
|
|
266
|
-
|
|
322
|
+
def _dune_find_tables_impl(
|
|
267
323
|
keyword: str | None = None,
|
|
268
324
|
schema: str | None = None,
|
|
269
325
|
limit: int = 50,
|
|
@@ -279,15 +335,183 @@ async def _dune_find_tables_impl(
|
|
|
279
335
|
return out
|
|
280
336
|
|
|
281
337
|
|
|
338
|
+
def _unified_discover_impl(
|
|
339
|
+
keyword: str | list[str] | None = None,
|
|
340
|
+
schema: str | None = None,
|
|
341
|
+
limit: int = 50,
|
|
342
|
+
source: Literal["dune", "spellbook", "both"] = "both",
|
|
343
|
+
include_columns: bool = True,
|
|
344
|
+
) -> dict[str, Any]:
|
|
345
|
+
"""
|
|
346
|
+
Unified discovery implementation that can search Dune API, Spellbook repo, or both.
|
|
347
|
+
|
|
348
|
+
Returns a consistent format with 'schemas' and 'tables' keys.
|
|
349
|
+
"""
|
|
350
|
+
_ensure_initialized()
|
|
351
|
+
out: dict[str, Any] = {
|
|
352
|
+
"schemas": [],
|
|
353
|
+
"tables": [],
|
|
354
|
+
"source": source,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# Normalize keyword to list
|
|
358
|
+
keywords = keyword if isinstance(keyword, list) else ([keyword] if keyword else [])
|
|
359
|
+
|
|
360
|
+
# Search Dune API if requested
|
|
361
|
+
if source in ("dune", "both"):
|
|
362
|
+
dune_result: dict[str, Any] = {}
|
|
363
|
+
if keyword:
|
|
364
|
+
assert DISCOVERY_SERVICE is not None
|
|
365
|
+
# Search each keyword and combine results
|
|
366
|
+
# DISCOVERY_SERVICE.find_schemas returns list[str], not SchemaMatch objects
|
|
367
|
+
all_schemas: set[str] = set()
|
|
368
|
+
for kw in keywords:
|
|
369
|
+
schemas = DISCOVERY_SERVICE.find_schemas(kw)
|
|
370
|
+
# schemas is already a list of strings from DiscoveryService
|
|
371
|
+
all_schemas.update(schemas)
|
|
372
|
+
dune_result["schemas"] = sorted(list(all_schemas))
|
|
373
|
+
|
|
374
|
+
if schema:
|
|
375
|
+
assert DISCOVERY_SERVICE is not None
|
|
376
|
+
tables = DISCOVERY_SERVICE.list_tables(schema, limit=limit)
|
|
377
|
+
dune_result["tables"] = [
|
|
378
|
+
{
|
|
379
|
+
"schema": schema,
|
|
380
|
+
"table": summary.table,
|
|
381
|
+
"fully_qualified_name": f"{schema}.{summary.table}",
|
|
382
|
+
"source": "dune",
|
|
383
|
+
}
|
|
384
|
+
for summary in tables
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
# Merge Dune results
|
|
388
|
+
if "schemas" in dune_result:
|
|
389
|
+
out["schemas"].extend(dune_result["schemas"])
|
|
390
|
+
if "tables" in dune_result:
|
|
391
|
+
out["tables"].extend(dune_result["tables"])
|
|
392
|
+
|
|
393
|
+
# Search Spellbook if requested
|
|
394
|
+
if source in ("spellbook", "both"):
|
|
395
|
+
spellbook_result = _spellbook_find_models_impl(
|
|
396
|
+
keyword=keyword,
|
|
397
|
+
schema=schema,
|
|
398
|
+
limit=limit,
|
|
399
|
+
include_columns=include_columns,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Convert spellbook models to unified format
|
|
403
|
+
if "schemas" in spellbook_result:
|
|
404
|
+
spellbook_schemas = spellbook_result["schemas"]
|
|
405
|
+
# Merge schemas (avoid duplicates)
|
|
406
|
+
existing_schemas = set(out["schemas"])
|
|
407
|
+
for s in spellbook_schemas:
|
|
408
|
+
if s not in existing_schemas:
|
|
409
|
+
out["schemas"].append(s)
|
|
410
|
+
|
|
411
|
+
if "models" in spellbook_result:
|
|
412
|
+
for model in spellbook_result["models"]:
|
|
413
|
+
table_info = {
|
|
414
|
+
"schema": model["schema"],
|
|
415
|
+
"table": model["table"],
|
|
416
|
+
"fully_qualified_name": model["fully_qualified_name"],
|
|
417
|
+
"source": "spellbook",
|
|
418
|
+
}
|
|
419
|
+
if "columns" in model:
|
|
420
|
+
table_info["columns"] = model["columns"]
|
|
421
|
+
out["tables"].append(table_info)
|
|
422
|
+
|
|
423
|
+
# Deduplicate and sort schemas
|
|
424
|
+
out["schemas"] = sorted(list(set(out["schemas"])))
|
|
425
|
+
|
|
426
|
+
# Limit total tables
|
|
427
|
+
if limit and len(out["tables"]) > limit:
|
|
428
|
+
out["tables"] = out["tables"][:limit]
|
|
429
|
+
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@app.tool(
|
|
434
|
+
name="dune_discover",
|
|
435
|
+
title="Discover Tables",
|
|
436
|
+
description="Unified tool to discover tables/models from Dune API and/or Spellbook repository. Search by keyword(s) or list tables in a schema.",
|
|
437
|
+
tags={"dune", "spellbook", "schema", "discovery"},
|
|
438
|
+
)
|
|
439
|
+
def dune_discover(
|
|
440
|
+
keyword: str | list[str] | None = None,
|
|
441
|
+
schema: str | None = None,
|
|
442
|
+
limit: int = 50,
|
|
443
|
+
source: Literal["dune", "spellbook", "both"] = "both",
|
|
444
|
+
include_columns: bool = True,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""
|
|
447
|
+
Unified discovery tool for Dune tables and Spellbook models.
|
|
448
|
+
|
|
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.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
keyword: Search term(s) - can be a string or list of strings
|
|
455
|
+
(e.g., "layerzero", ["layerzero", "dex"], "nft")
|
|
456
|
+
schema: Schema name to list tables from (e.g., "dex", "spellbook", "layerzero")
|
|
457
|
+
limit: Maximum number of tables to return
|
|
458
|
+
source: Where to search - "dune" (Dune API only), "spellbook" (GitHub repo only),
|
|
459
|
+
or "both" (default: searches both and merges results)
|
|
460
|
+
include_columns: Whether to include column details for Spellbook models (default: True)
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Dictionary with:
|
|
464
|
+
- 'schemas': List of matching schema names
|
|
465
|
+
- 'tables': List of table/model objects, each with:
|
|
466
|
+
- schema: Schema name
|
|
467
|
+
- table: Table/model name
|
|
468
|
+
- fully_qualified_name: schema.table
|
|
469
|
+
- source: "dune" or "spellbook"
|
|
470
|
+
- columns: Column details (for Spellbook models, if include_columns=True)
|
|
471
|
+
- 'source': The source parameter used
|
|
472
|
+
|
|
473
|
+
Examples:
|
|
474
|
+
# Search both sources for layerzero
|
|
475
|
+
dune_discover(keyword="layerzero")
|
|
476
|
+
|
|
477
|
+
# Search only Spellbook
|
|
478
|
+
dune_discover(keyword=["layerzero", "bridge"], source="spellbook")
|
|
479
|
+
|
|
480
|
+
# Search only Dune API
|
|
481
|
+
dune_discover(keyword="sui", source="dune")
|
|
482
|
+
|
|
483
|
+
# List all tables in a schema (searches both sources)
|
|
484
|
+
dune_discover(schema="dex")
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
return _unified_discover_impl(
|
|
488
|
+
keyword=keyword,
|
|
489
|
+
schema=schema,
|
|
490
|
+
limit=limit,
|
|
491
|
+
source=source,
|
|
492
|
+
include_columns=include_columns,
|
|
493
|
+
)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
return error_response(e, context={
|
|
496
|
+
"tool": "dune_discover",
|
|
497
|
+
"keyword": keyword,
|
|
498
|
+
"schema": schema,
|
|
499
|
+
"source": source,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
|
|
282
503
|
@app.tool(
|
|
283
504
|
name="dune_find_tables",
|
|
284
505
|
title="Find Tables",
|
|
285
506
|
description="Search schemas and optionally list tables.",
|
|
286
507
|
tags={"dune", "schema"},
|
|
287
508
|
)
|
|
288
|
-
|
|
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
|
+
"""
|
|
289
513
|
try:
|
|
290
|
-
return
|
|
514
|
+
return _dune_find_tables_impl(keyword=keyword, schema=schema, limit=limit)
|
|
291
515
|
except Exception as e:
|
|
292
516
|
return error_response(e, context={
|
|
293
517
|
"tool": "dune_find_tables",
|
|
@@ -296,7 +520,7 @@ async def dune_find_tables(keyword: str | None = None, schema: str | None = None
|
|
|
296
520
|
})
|
|
297
521
|
|
|
298
522
|
|
|
299
|
-
|
|
523
|
+
def _dune_describe_table_impl(schema: str, table: str) -> dict[str, Any]:
|
|
300
524
|
_ensure_initialized()
|
|
301
525
|
assert DISCOVERY_SERVICE is not None
|
|
302
526
|
desc = DISCOVERY_SERVICE.describe_table(schema, table)
|
|
@@ -320,9 +544,9 @@ async def _dune_describe_table_impl(schema: str, table: str) -> dict[str, Any]:
|
|
|
320
544
|
description="Describe columns for a schema.table on Dune.",
|
|
321
545
|
tags={"dune", "schema"},
|
|
322
546
|
)
|
|
323
|
-
|
|
547
|
+
def dune_describe_table(schema: str, table: str) -> dict[str, Any]:
|
|
324
548
|
try:
|
|
325
|
-
return
|
|
549
|
+
return _dune_describe_table_impl(schema=schema, table=table)
|
|
326
550
|
except Exception as e:
|
|
327
551
|
return error_response(e, context={
|
|
328
552
|
"tool": "dune_describe_table",
|
|
@@ -331,68 +555,151 @@ async def dune_describe_table(schema: str, table: str) -> dict[str, Any]:
|
|
|
331
555
|
})
|
|
332
556
|
|
|
333
557
|
|
|
558
|
+
def _spellbook_find_models_impl(
|
|
559
|
+
keyword: str | list[str] | None = None,
|
|
560
|
+
schema: str | None = None,
|
|
561
|
+
limit: int = 50,
|
|
562
|
+
include_columns: bool = True,
|
|
563
|
+
) -> dict[str, Any]:
|
|
564
|
+
"""
|
|
565
|
+
Implementation for spellbook model discovery.
|
|
566
|
+
|
|
567
|
+
Supports searching by keyword(s) and optionally includes column details.
|
|
568
|
+
"""
|
|
569
|
+
_ensure_initialized()
|
|
570
|
+
assert SPELLBOOK_EXPLORER is not None
|
|
571
|
+
out: dict[str, Any] = {}
|
|
572
|
+
|
|
573
|
+
# Handle keyword search (string or list)
|
|
574
|
+
if keyword:
|
|
575
|
+
# Normalize to list
|
|
576
|
+
keywords = keyword if isinstance(keyword, list) else [keyword]
|
|
577
|
+
|
|
578
|
+
# Find schemas matching any keyword
|
|
579
|
+
all_schemas: set[str] = set()
|
|
580
|
+
for kw in keywords:
|
|
581
|
+
schemas = SPELLBOOK_EXPLORER.find_schemas(kw)
|
|
582
|
+
all_schemas.update(match.schema for match in schemas)
|
|
583
|
+
|
|
584
|
+
out["schemas"] = sorted(list(all_schemas))
|
|
585
|
+
|
|
586
|
+
# If schema not specified but we found schemas, search models in those schemas
|
|
587
|
+
if not schema and all_schemas:
|
|
588
|
+
out["models"] = []
|
|
589
|
+
for schema_name in sorted(all_schemas):
|
|
590
|
+
tables = SPELLBOOK_EXPLORER.list_tables(schema_name, limit=limit)
|
|
591
|
+
for table_summary in tables:
|
|
592
|
+
# Check if table name matches any keyword
|
|
593
|
+
table_name = table_summary.table.lower()
|
|
594
|
+
matches_keyword = any(kw.lower() in table_name for kw in keywords)
|
|
595
|
+
|
|
596
|
+
if matches_keyword:
|
|
597
|
+
model_info: dict[str, Any] = {
|
|
598
|
+
"schema": schema_name,
|
|
599
|
+
"table": table_summary.table,
|
|
600
|
+
"fully_qualified_name": f"{schema_name}.{table_summary.table}",
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
# Include column details if requested
|
|
604
|
+
if include_columns:
|
|
605
|
+
try:
|
|
606
|
+
desc = SPELLBOOK_EXPLORER.describe_table(schema_name, table_summary.table)
|
|
607
|
+
model_info["columns"] = [
|
|
608
|
+
{
|
|
609
|
+
"name": col.name,
|
|
610
|
+
"dune_type": col.dune_type,
|
|
611
|
+
"polars_dtype": col.polars_dtype,
|
|
612
|
+
"comment": col.comment,
|
|
613
|
+
}
|
|
614
|
+
for col in desc.columns
|
|
615
|
+
]
|
|
616
|
+
except Exception:
|
|
617
|
+
model_info["columns"] = []
|
|
618
|
+
|
|
619
|
+
out["models"].append(model_info)
|
|
620
|
+
|
|
621
|
+
# Limit total models returned
|
|
622
|
+
if limit and len(out["models"]) > limit:
|
|
623
|
+
out["models"] = out["models"][:limit]
|
|
624
|
+
|
|
625
|
+
# If schema specified, list all tables in that schema
|
|
626
|
+
if schema:
|
|
627
|
+
tables = SPELLBOOK_EXPLORER.list_tables(schema, limit=limit)
|
|
628
|
+
if "models" not in out:
|
|
629
|
+
out["models"] = []
|
|
630
|
+
|
|
631
|
+
for table_summary in tables:
|
|
632
|
+
model_info: dict[str, Any] = {
|
|
633
|
+
"schema": schema,
|
|
634
|
+
"table": table_summary.table,
|
|
635
|
+
"fully_qualified_name": f"{schema}.{table_summary.table}",
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Include column details if requested
|
|
639
|
+
if include_columns:
|
|
640
|
+
try:
|
|
641
|
+
desc = SPELLBOOK_EXPLORER.describe_table(schema, table_summary.table)
|
|
642
|
+
model_info["columns"] = [
|
|
643
|
+
{
|
|
644
|
+
"name": col.name,
|
|
645
|
+
"dune_type": col.dune_type,
|
|
646
|
+
"polars_dtype": col.polars_dtype,
|
|
647
|
+
"comment": col.comment,
|
|
648
|
+
}
|
|
649
|
+
for col in desc.columns
|
|
650
|
+
]
|
|
651
|
+
except Exception:
|
|
652
|
+
model_info["columns"] = []
|
|
653
|
+
|
|
654
|
+
out["models"].append(model_info)
|
|
655
|
+
|
|
656
|
+
return out
|
|
657
|
+
|
|
658
|
+
|
|
334
659
|
@app.tool(
|
|
335
|
-
name="
|
|
336
|
-
title="
|
|
337
|
-
description="
|
|
338
|
-
tags={"
|
|
660
|
+
name="spellbook_find_models",
|
|
661
|
+
title="Search Spellbook",
|
|
662
|
+
description="Search dbt models in Spellbook GitHub repository.",
|
|
663
|
+
tags={"spellbook", "dbt", "schema"},
|
|
339
664
|
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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,
|
|
344
670
|
) -> dict[str, Any]:
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
"""
|
|
347
683
|
try:
|
|
348
|
-
return
|
|
349
|
-
|
|
684
|
+
return _spellbook_find_models_impl(
|
|
685
|
+
keyword=keyword,
|
|
686
|
+
schema=schema,
|
|
687
|
+
limit=limit,
|
|
688
|
+
include_columns=include_columns,
|
|
350
689
|
)
|
|
351
690
|
except Exception as e:
|
|
352
691
|
return error_response(e, context={
|
|
353
|
-
"tool": "
|
|
354
|
-
"
|
|
355
|
-
"
|
|
692
|
+
"tool": "spellbook_find_models",
|
|
693
|
+
"keyword": keyword,
|
|
694
|
+
"schema": schema,
|
|
356
695
|
})
|
|
357
696
|
|
|
358
697
|
|
|
359
|
-
@app.resource(uri="spice:sui/events_preview/{hours}/{limit}/{packages}", name="Sui Events Preview", description="Preview Sui events (3-day default) for comma-separated packages; returns JSON.")
|
|
360
|
-
async def sui_events_preview_resource(hours: str, limit: str, packages: str) -> str:
|
|
361
|
-
import json
|
|
362
|
-
|
|
363
|
-
try:
|
|
364
|
-
hh = int(hours)
|
|
365
|
-
except Exception:
|
|
366
|
-
hh = 72
|
|
367
|
-
try:
|
|
368
|
-
ll = int(limit)
|
|
369
|
-
except Exception:
|
|
370
|
-
ll = 50
|
|
371
|
-
pkgs = []
|
|
372
|
-
if packages and packages != "_":
|
|
373
|
-
pkgs = [p.strip() for p in packages.split(",") if p.strip()]
|
|
374
|
-
|
|
375
|
-
_ensure_initialized()
|
|
376
|
-
assert SUI_SERVICE is not None
|
|
377
|
-
try:
|
|
378
|
-
result = SUI_SERVICE.events_preview(pkgs, hours=hh, limit=ll)
|
|
379
|
-
payload = {"ok": True, **result}
|
|
380
|
-
except Exception as exc:
|
|
381
|
-
payload = error_response(
|
|
382
|
-
exc,
|
|
383
|
-
context={
|
|
384
|
-
"resource": "sui_events_preview",
|
|
385
|
-
"packages": pkgs,
|
|
386
|
-
"hours": hh,
|
|
387
|
-
"limit": ll,
|
|
388
|
-
},
|
|
389
|
-
)
|
|
390
|
-
return json.dumps(payload)
|
|
391
698
|
|
|
392
699
|
|
|
393
700
|
# Resources
|
|
394
701
|
@app.resource(uri="spice:history/tail/{n}", name="Query History Tail", description="Tail last N lines from query history")
|
|
395
|
-
|
|
702
|
+
def history_tail(n: str) -> str:
|
|
396
703
|
from collections import deque
|
|
397
704
|
try:
|
|
398
705
|
nn = int(n)
|
|
@@ -418,7 +725,7 @@ async def history_tail(n: str) -> str:
|
|
|
418
725
|
|
|
419
726
|
|
|
420
727
|
@app.resource(uri="spice:artifact/{sha}", name="SQL Artifact", description="SQL artifact by SHA-256")
|
|
421
|
-
|
|
728
|
+
def sql_artifact(sha: str) -> str:
|
|
422
729
|
import os
|
|
423
730
|
import re
|
|
424
731
|
|
|
@@ -437,43 +744,6 @@ async def sql_artifact(sha: str) -> str:
|
|
|
437
744
|
return ""
|
|
438
745
|
|
|
439
746
|
|
|
440
|
-
@app.resource(
|
|
441
|
-
uri="spice:sui/package_overview/{hours}/{timeout_seconds}/{packages}",
|
|
442
|
-
name="Sui Package Overview (cmd)",
|
|
443
|
-
description="Compact overview for Sui package activity as a command-style resource."
|
|
444
|
-
)
|
|
445
|
-
async def sui_package_overview_cmd(hours: str, timeout_seconds: str, packages: str) -> str:
|
|
446
|
-
import json
|
|
447
|
-
|
|
448
|
-
try:
|
|
449
|
-
hh = int(hours)
|
|
450
|
-
except Exception:
|
|
451
|
-
hh = 72
|
|
452
|
-
try:
|
|
453
|
-
tt = float(timeout_seconds)
|
|
454
|
-
except Exception:
|
|
455
|
-
tt = 30.0
|
|
456
|
-
pkgs = []
|
|
457
|
-
if packages and packages != "_":
|
|
458
|
-
pkgs = [p.strip() for p in packages.split(",") if p.strip()]
|
|
459
|
-
|
|
460
|
-
_ensure_initialized()
|
|
461
|
-
assert SUI_SERVICE is not None
|
|
462
|
-
try:
|
|
463
|
-
result = SUI_SERVICE.package_overview(pkgs, hours=hh, timeout_seconds=tt)
|
|
464
|
-
except Exception as exc:
|
|
465
|
-
result = error_response(
|
|
466
|
-
exc,
|
|
467
|
-
context={
|
|
468
|
-
"resource": "sui_package_overview",
|
|
469
|
-
"packages": pkgs,
|
|
470
|
-
"hours": hh,
|
|
471
|
-
"timeout_seconds": tt,
|
|
472
|
-
},
|
|
473
|
-
)
|
|
474
|
-
return json.dumps(result)
|
|
475
|
-
|
|
476
|
-
|
|
477
747
|
def main() -> None:
|
|
478
748
|
# Do not initialize at startup; defer until first tool call so env issues
|
|
479
749
|
# don't break MCP handshake. Disable banner to keep stdio clean.
|
|
@@ -488,7 +758,7 @@ if __name__ == "__main__":
|
|
|
488
758
|
description="Create a new saved Dune query (name + SQL).",
|
|
489
759
|
tags={"dune", "admin"},
|
|
490
760
|
)
|
|
491
|
-
|
|
761
|
+
def dune_query_create(name: str, query_sql: str, description: str | None = None, tags: list[str] | None = None, parameters: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
492
762
|
_ensure_initialized()
|
|
493
763
|
assert QUERY_ADMIN_SERVICE is not None
|
|
494
764
|
try:
|
|
@@ -503,7 +773,7 @@ async def dune_query_create(name: str, query_sql: str, description: str | None =
|
|
|
503
773
|
description="Update fields of a saved Dune query (name/SQL/description/tags/parameters).",
|
|
504
774
|
tags={"dune", "admin"},
|
|
505
775
|
)
|
|
506
|
-
|
|
776
|
+
def dune_query_update(query_id: int, name: str | None = None, query_sql: str | None = None, description: str | None = None, tags: list[str] | None = None, parameters: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
507
777
|
_ensure_initialized()
|
|
508
778
|
assert QUERY_ADMIN_SERVICE is not None
|
|
509
779
|
try:
|
|
@@ -518,7 +788,7 @@ async def dune_query_update(query_id: int, name: str | None = None, query_sql: s
|
|
|
518
788
|
description="Fork an existing saved Dune query.",
|
|
519
789
|
tags={"dune", "admin"},
|
|
520
790
|
)
|
|
521
|
-
|
|
791
|
+
def dune_query_fork(source_query_id: int, name: str | None = None) -> dict[str, Any]:
|
|
522
792
|
_ensure_initialized()
|
|
523
793
|
assert QUERY_ADMIN_SERVICE is not None
|
|
524
794
|
try:
|