271 lines
8.3 KiB
Python
271 lines
8.3 KiB
Python
"""
|
|
MCP voice agent that routes queries either to Firecrawl web search or to Supabase via MCP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any, Callable, List, Optional
|
|
|
|
import inspect
|
|
from dotenv import load_dotenv
|
|
from firecrawl import FirecrawlApp, ScrapeOptions
|
|
from pydantic_ai.mcp import MCPServerStdio
|
|
|
|
from livekit.agents import (
|
|
Agent,
|
|
AgentSession,
|
|
JobContext,
|
|
RunContext,
|
|
WorkerOptions,
|
|
cli,
|
|
function_tool,
|
|
)
|
|
from livekit.plugins import assemblyai, openai, silero
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Configuration & Logging
|
|
# ------------------------------------------------------------------------------
|
|
load_dotenv()
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY")
|
|
SUPABASE_TOKEN = os.getenv("SUPABASE_ACCESS_TOKEN")
|
|
|
|
if not FIRECRAWL_API_KEY:
|
|
logger.error("FIRECRAWL_API_KEY is not set in environment.")
|
|
raise EnvironmentError("Please set FIRECRAWL_API_KEY env var.")
|
|
|
|
if not SUPABASE_TOKEN:
|
|
logger.error("SUPABASE_ACCESS_TOKEN is not set in environment.")
|
|
raise EnvironmentError("Please set SUPABASE_ACCESS_TOKEN env var.")
|
|
|
|
firecrawl_app = FirecrawlApp(api_key=FIRECRAWL_API_KEY)
|
|
|
|
|
|
def _py_type(schema: dict) -> Any:
|
|
"""Convert JSON schema types into Python typing annotations."""
|
|
t = schema.get("type")
|
|
mapping = {
|
|
"string": str,
|
|
"integer": int,
|
|
"number": float,
|
|
"boolean": bool,
|
|
"object": dict,
|
|
}
|
|
|
|
if isinstance(t, list):
|
|
if "array" in t:
|
|
return List[_py_type(schema.get("items", {}))]
|
|
t = t[0]
|
|
|
|
if isinstance(t, str) and t in mapping:
|
|
return mapping[t]
|
|
if t == "array":
|
|
return List[_py_type(schema.get("items", {}))]
|
|
|
|
return Any
|
|
|
|
|
|
def schema_to_google_docstring(description: str, schema: dict) -> str:
|
|
"""
|
|
Generate a Google-style docstring section from a JSON schema.
|
|
"""
|
|
props = schema.get("properties", {})
|
|
required = set(schema.get("required", []))
|
|
lines = [description or "", "Args:"]
|
|
|
|
for name, prop in props.items():
|
|
t = prop.get("type", "Any")
|
|
if isinstance(t, list):
|
|
if "array" in t:
|
|
subtype = prop.get("items", {}).get("type", "Any")
|
|
py_type = f"List[{subtype.capitalize()}]"
|
|
else:
|
|
py_type = t[0].capitalize()
|
|
elif t == "array":
|
|
subtype = prop.get("items", {}).get("type", "Any")
|
|
py_type = f"List[{subtype.capitalize()}]"
|
|
else:
|
|
py_type = t.capitalize()
|
|
|
|
if name not in required:
|
|
py_type = f"Optional[{py_type}]"
|
|
|
|
desc = prop.get("description", "")
|
|
lines.append(f" {name} ({py_type}): {desc}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
@function_tool
|
|
async def firecrawl_search(
|
|
context: RunContext,
|
|
query: str,
|
|
limit: int = 5
|
|
) -> List[str]:
|
|
"""
|
|
Search the web via Firecrawl.
|
|
|
|
Args:
|
|
context (RunContext): LiveKit runtime context.
|
|
query (str): Search query string.
|
|
limit (int): Maximum pages to crawl.
|
|
|
|
Returns:
|
|
List[str]: Raw page contents.
|
|
"""
|
|
url = f"https://www.google.com/search?q={query}"
|
|
logger.debug("Starting Firecrawl for URL: %s (limit=%d)", url, limit)
|
|
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda: firecrawl_app.crawl_url(
|
|
url,
|
|
limit=limit,
|
|
scrape_options=ScrapeOptions(formats=["text", "markdown"])
|
|
)
|
|
)
|
|
logger.info("Firecrawl returned %d pages", len(result))
|
|
return result
|
|
except Exception as e:
|
|
logger.error("Firecrawl search failed: %s", e, exc_info=True)
|
|
return []
|
|
|
|
|
|
async def build_livekit_tools(server: MCPServerStdio) -> List[Callable]:
|
|
"""
|
|
Build LiveKit tools from a Supabase MCP server.
|
|
"""
|
|
tools: List[Callable] = []
|
|
all_tools = await server.list_tools()
|
|
logger.info("Found %d MCP tools", len(all_tools))
|
|
|
|
for td in all_tools:
|
|
if td.name == "deploy_edge_function":
|
|
logger.warning("Skipping tool %s", td.name)
|
|
continue
|
|
|
|
schema = copy.deepcopy(td.parameters_json_schema)
|
|
if td.name == "list_tables":
|
|
props = schema.setdefault("properties", {})
|
|
props["schemas"] = {
|
|
"type": ["array", "null"],
|
|
"items": {"type": "string"},
|
|
"default": []
|
|
}
|
|
schema["required"] = [r for r in schema.get("required", []) if r != "schemas"]
|
|
|
|
props = schema.get("properties", {})
|
|
required = set(schema.get("required", []))
|
|
|
|
def make_proxy(
|
|
tool_def=td,
|
|
_props=props,
|
|
_required=required,
|
|
_schema=schema
|
|
) -> Callable:
|
|
async def proxy(context: RunContext, **kwargs):
|
|
# Convert None → [] for array params
|
|
for k, v in list(kwargs.items()):
|
|
if ((_props[k].get("type") == "array"
|
|
or "array" in (_props[k].get("type") or []))
|
|
and v is None):
|
|
kwargs[k] = []
|
|
|
|
response = await server.call_tool(tool_def.name, arguments=kwargs or None)
|
|
if isinstance(response, list):
|
|
return response
|
|
if hasattr(response, "content") and response.content:
|
|
text = response.content[0].text
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return text
|
|
return response
|
|
|
|
# Build signature from schema
|
|
params = [
|
|
inspect.Parameter("context", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=RunContext)
|
|
]
|
|
ann = {"context": RunContext}
|
|
|
|
for name, ps in _props.items():
|
|
default = ps.get("default", inspect._empty if name in required else None)
|
|
params.append(
|
|
inspect.Parameter(
|
|
name,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=_py_type(ps),
|
|
default=default,
|
|
)
|
|
)
|
|
ann[name] = _py_type(ps)
|
|
|
|
proxy.__signature__ = inspect.Signature(params)
|
|
proxy.__annotations__ = ann
|
|
proxy.__name__ = tool_def.name
|
|
proxy.__doc__ = schema_to_google_docstring(tool_def.description or "", _schema)
|
|
return function_tool(proxy)
|
|
|
|
tools.append(make_proxy())
|
|
|
|
return tools
|
|
|
|
|
|
async def entrypoint(ctx: JobContext) -> None:
|
|
"""
|
|
Main entrypoint for the LiveKit agent.
|
|
"""
|
|
await ctx.connect()
|
|
server = MCPServerStdio(
|
|
"npx",
|
|
args=["-y", "@supabase/mcp-server-supabase@latest", "--access-token", SUPABASE_TOKEN],
|
|
)
|
|
await server.__aenter__()
|
|
|
|
try:
|
|
supabase_tools = await build_livekit_tools(server)
|
|
tools = [firecrawl_search] + supabase_tools
|
|
|
|
agent = Agent(
|
|
instructions=(
|
|
"You can either perform live web searches via `firecrawl_search` or "
|
|
"database queries via Supabase MCP tools. "
|
|
"Choose the appropriate tool based on whether the user needs fresh web data "
|
|
"(news, external facts) or internal Supabase data."
|
|
),
|
|
tools=tools,
|
|
)
|
|
|
|
session = AgentSession(
|
|
vad=silero.VAD.load(min_silence_duration=0.1),
|
|
stt=assemblyai.STT(word_boost=["Supabase"]),
|
|
llm=openai.LLM(model="gpt-4o"),
|
|
tts=openai.TTS(voice="ash"),
|
|
)
|
|
|
|
await session.start(agent=agent, room=ctx.room)
|
|
await session.generate_reply(instructions="Hello! How can I assist you today?")
|
|
|
|
# Keep the session alive until cancelled
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
logger.info("Session cancelled, shutting down.")
|
|
|
|
finally:
|
|
await server.__aexit__(None, None, None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
|