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/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
- SUI_SERVICE: SuiService | None = None
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, SUI_SERVICE, QUERY_ADMIN_SERVICE
64
- global EXECUTE_QUERY_TOOL, SUI_OVERVIEW_TOOL, HTTP_CLIENT
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
- SUI_SERVICE = SuiService(QUERY_SERVICE)
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": dune_extract.get_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
- async def dune_query_info(query: str) -> dict[str, Any]:
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": dune_extract.get_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
- @app.tool(
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] | None = None,
203
+ parameters: Optional[dict[str, Any]] = None,
218
204
  refresh: bool = False,
219
- max_age: float | None = None,
220
- limit: int | None = None,
221
- offset: int | None = None,
222
- sample_count: int | None = None,
223
- sort_by: str | None = None,
224
- columns: list[str] | None = None,
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] | None = None,
227
- timeout_seconds: float | None = None,
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 directly without semaphore concurrency control
233
- return await EXECUTE_QUERY_TOOL.execute(
246
+ # Execute query synchronously
247
+ return EXECUTE_QUERY_TOOL.execute(
234
248
  query=query,
235
- parameters=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=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
- async def dune_health_check() -> dict[str, Any]:
318
+ def dune_health_check() -> dict[str, Any]:
263
319
  return compute_health_status()
264
320
 
265
321
 
266
- async def _dune_find_tables_impl(
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
- async def dune_find_tables(keyword: str | None = None, schema: str | None = None, limit: int = 50) -> dict[str, Any]:
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 await _dune_find_tables_impl(keyword=keyword, schema=schema, limit=limit)
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
- async def _dune_describe_table_impl(schema: str, table: str) -> dict[str, Any]:
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
- async def dune_describe_table(schema: str, table: str) -> dict[str, Any]:
547
+ def dune_describe_table(schema: str, table: str) -> dict[str, Any]:
324
548
  try:
325
- return await _dune_describe_table_impl(schema=schema, table=table)
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="sui_package_overview",
336
- title="Sui Package Overview",
337
- description="Compact overview for Sui package activity.",
338
- tags={"sui"},
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
- async def sui_package_overview(
341
- packages: list[str],
342
- hours: int = 72,
343
- timeout_seconds: float | None = 30,
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
- _ensure_initialized()
346
- assert SUI_OVERVIEW_TOOL is not None
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 await SUI_OVERVIEW_TOOL.execute(
349
- packages=packages, hours=hours, timeout_seconds=timeout_seconds
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": "sui_package_overview",
354
- "packages": packages,
355
- "hours": hours,
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
- async def history_tail(n: str) -> str:
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
- async def sql_artifact(sha: str) -> str:
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
- async 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]:
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
- async 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]:
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
- async def dune_query_fork(source_query_id: int, name: str | None = None) -> dict[str, Any]:
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: