Files
patchy631--ai-engineering-hub/mcp-voice-agent/agent.py
T
2026-07-13 12:37:47 +08:00

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))