Skip to content

Commit 4bb720c

Browse files
zzstoatzzclaude
andauthored
feat: add slack-search MCP server with dynamic type filters (#1266)
* feat: add slack-search MCP server with dynamic type filters adds an MCP server for searching AI-generated summaries of Prefect Slack community threads stored in Turso. key features: - semantic search using Voyage AI embeddings - text search across thread titles and summaries - dynamic Literal types for topic/channel filters loaded at startup (LLMs see available filter options directly in tool schema) - thread detail retrieval with full metadata also includes scripts for: - indexing Slack threads into Turso with embeddings - backfilling embeddings for existing records - generating HTML analytics reports with Plotly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move slack-search MCP to examples/slack_search 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address review feedback from copilot - use parameterized queries for LIMIT values (SQL injection risk) - escape LIKE wildcards in search queries - add error handling for malformed Turso responses - use webbrowser.open() instead of macOS-specific subprocess - add comments explaining magic numbers and silent except blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bec525b commit 4bb720c

10 files changed

Lines changed: 3759 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ wheels/
2626
**/*.sqlite
2727
sandbox/
2828
.research_cache/
29+
report.html
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
[project]
2+
name = "slack-search"
3+
description = "MCP server for searching Prefect Slack thread summaries"
4+
license = "MIT"
5+
authors = [{ name = "Prefect" }]
6+
requires-python = ">=3.10"
7+
dynamic = ["version"]
8+
9+
dependencies = [
10+
"fastmcp>=2.0",
11+
"httpx>=0.28",
12+
"pydantic>=2.0",
13+
]
14+
15+
classifiers = [
16+
"Programming Language :: Python :: 3.10",
17+
"Programming Language :: Python :: 3.11",
18+
"Programming Language :: Python :: 3.12",
19+
"Programming Language :: Python :: 3.13",
20+
]
21+
22+
[project.scripts]
23+
slack-search = "slack_search.server:main"
24+
25+
[build-system]
26+
requires = ["hatchling", "uv-dynamic-versioning"]
27+
build-backend = "hatchling.build"
28+
29+
[tool.hatch.version]
30+
source = "uv-dynamic-versioning"
31+
32+
[tool.uv-dynamic-versioning]
33+
enable = true
34+
style = "pep440"
35+
fallback-version = "0.0.0"
36+
37+
[tool.hatch.build.targets.wheel]
38+
packages = ["src/slack_search"]
39+
40+
[tool.ruff]
41+
line-length = 100
42+
target-version = "py310"
43+
44+
[tool.ruff.lint]
45+
select = ["E", "F", "I", "UP"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""MCP server for searching Prefect Slack thread summaries."""
2+
3+
from slack_search.server import mcp
4+
5+
__all__ = ["mcp"]
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Type definitions for slack search MCP responses."""
2+
3+
from pydantic import BaseModel, computed_field
4+
5+
6+
class ThreadSummary(BaseModel):
7+
"""A search result representing a Slack thread summary."""
8+
9+
key: str
10+
name: str
11+
description: str
12+
preview: str = ""
13+
score: float | None = None # similarity score for semantic search
14+
15+
@computed_field
16+
@property
17+
def channel_id(self) -> str:
18+
"""Extract channel ID from key."""
19+
# key format: slack://workspace/bot/BOT_ID/summary/CHANNEL_ID/THREAD_TS
20+
parts = self.key.split("/")
21+
if len(parts) >= 6:
22+
return parts[5]
23+
return ""
24+
25+
@computed_field
26+
@property
27+
def thread_ts(self) -> str:
28+
"""Extract thread timestamp from key."""
29+
parts = self.key.split("/")
30+
if len(parts) >= 7:
31+
return parts[6]
32+
return ""
33+
34+
35+
class ThreadDetail(BaseModel):
36+
"""Full thread details including metadata."""
37+
38+
key: str
39+
name: str
40+
description: str
41+
last_seen: str
42+
metadata: dict
43+
44+
@computed_field
45+
@property
46+
def title(self) -> str:
47+
"""Thread title from metadata."""
48+
return self.metadata.get("title", self.name)
49+
50+
@computed_field
51+
@property
52+
def summary(self) -> str:
53+
"""Full summary text."""
54+
return self.metadata.get("summary", "")
55+
56+
@computed_field
57+
@property
58+
def key_topics(self) -> list[str]:
59+
"""Key topics discussed in the thread."""
60+
return self.metadata.get("key_topics", [])
61+
62+
@computed_field
63+
@property
64+
def message_count(self) -> int:
65+
"""Number of messages in the thread."""
66+
return self.metadata.get("message_count", 0)
67+
68+
@computed_field
69+
@property
70+
def participant_count(self) -> int:
71+
"""Number of participants in the thread."""
72+
return self.metadata.get("participant_count", 0)
73+
74+
@computed_field
75+
@property
76+
def channel_id(self) -> str:
77+
"""Slack channel ID."""
78+
return self.metadata.get("channel_id", "")
79+
80+
@computed_field
81+
@property
82+
def thread_ts(self) -> str:
83+
"""Thread timestamp."""
84+
return self.metadata.get("thread_ts", "")
85+
86+
@computed_field
87+
@property
88+
def workspace(self) -> str:
89+
"""Slack workspace name."""
90+
return self.metadata.get("workspace_name", "")
91+
92+
93+
class Stats(BaseModel):
94+
"""Index statistics."""
95+
96+
total_threads: int
97+
with_embeddings: int
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Turso client for slack thread search."""
2+
3+
import os
4+
from typing import Any
5+
6+
import httpx
7+
8+
TURSO_URL = os.environ.get("TURSO_URL", "")
9+
TURSO_TOKEN = os.environ.get("TURSO_TOKEN", "")
10+
VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
11+
12+
13+
def _get_turso_host() -> str:
14+
"""Strip libsql:// prefix if present."""
15+
url = TURSO_URL
16+
if url.startswith("libsql://"):
17+
url = url[len("libsql://") :]
18+
return url
19+
20+
21+
async def turso_query(sql: str, args: list | None = None) -> list[dict[str, Any]]:
22+
"""Execute a query against Turso and return rows."""
23+
if not TURSO_URL or not TURSO_TOKEN:
24+
raise RuntimeError("TURSO_URL and TURSO_TOKEN must be set")
25+
26+
stmt: dict[str, Any] = {"sql": sql}
27+
if args:
28+
stmt["args"] = [{"type": "text", "value": str(a)} for a in args]
29+
30+
async with httpx.AsyncClient() as client:
31+
response = await client.post(
32+
f"https://{_get_turso_host()}/v2/pipeline",
33+
headers={
34+
"Authorization": f"Bearer {TURSO_TOKEN}",
35+
"Content-Type": "application/json",
36+
},
37+
json={"requests": [{"type": "execute", "stmt": stmt}, {"type": "close"}]},
38+
timeout=30,
39+
)
40+
response.raise_for_status()
41+
data = response.json()
42+
43+
result = data["results"][0]
44+
if result["type"] == "error":
45+
raise Exception(f"Turso error: {result['error']}")
46+
47+
cols = [c["name"] for c in result["response"]["result"]["cols"]]
48+
rows = result["response"]["result"]["rows"]
49+
50+
def extract_value(cell: Any) -> Any:
51+
if cell is None:
52+
return None
53+
if isinstance(cell, dict):
54+
return cell.get("value")
55+
return cell
56+
57+
return [dict(zip(cols, [extract_value(cell) for cell in row])) for row in rows]
58+
59+
60+
async def voyage_embed(text: str) -> list[float]:
61+
"""Generate embedding for a query using Voyage AI."""
62+
if not VOYAGE_API_KEY:
63+
raise RuntimeError("VOYAGE_API_KEY must be set for semantic search")
64+
65+
async with httpx.AsyncClient() as client:
66+
response = await client.post(
67+
"https://api.voyageai.com/v1/embeddings",
68+
headers={
69+
"Authorization": f"Bearer {VOYAGE_API_KEY}",
70+
"Content-Type": "application/json",
71+
},
72+
json={
73+
"input": [text],
74+
"model": "voyage-3-lite",
75+
"input_type": "query",
76+
},
77+
timeout=30,
78+
)
79+
response.raise_for_status()
80+
data = response.json()
81+
82+
return data["data"][0]["embedding"]

0 commit comments

Comments
 (0)