ndurner's picture
add comments
0c163b8
from __future__ import annotations
import os
from dataclasses import asdict, dataclass
import shutil
import subprocess
from fastmcp import FastMCP
from aileen3_mcp.media_tools import register_media_tools, _silence_stdio, _YDLLogger
from aileen3_mcp.logging_utils import configure_logging
import logging
log = logging.getLogger(__name__)
@dataclass
class HealthResult:
ok: bool
detail: str
def make_app() -> FastMCP:
"""Create the MCP application with all tools registered on a FastMCP instance.
This function is the single entry point for tool registration:
- it wires up lightweight health checks used by the demo Space, and
- it delegates to :func:`register_media_tools` for all long-running media flows.
"""
app = FastMCP("aileen3-mcp")
@app.tool()
def health() -> dict:
"""Return a basic health payload including ffmpeg and Gemini env availability.
This mirrors the Gradio health cell and is intentionally cheap:
it only checks for the *presence* of ffmpeg and a Gemini API key,
leaving deeper checks to the demo-side health probe.
"""
def _ffmpeg_ok() -> tuple[bool, str]:
# Keep this probe very small: just check that the binary is
# callable and returns a version string, without running any
# actual media processing pipeline.
binary = shutil.which("ffmpeg")
if not binary:
return False, "ffmpeg not found on PATH"
try:
completed = subprocess.run(
[binary, "-version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception as exc: # pragma: no cover - defensive
return False, f"ffmpeg exec failed: {exc}"
if completed.returncode != 0:
return False, completed.stderr.strip() or "ffmpeg returned error"
first = (completed.stdout or "").splitlines()[0] if completed.stdout else "ffmpeg present"
return True, first
def _gemini_key_ok() -> tuple[bool, str]:
key = bool(os.environ.get("GEMINI_API_KEY"))
return (key, "GEMINI_API_KEY is set" if key else "GEMINI_API_KEY missing")
ff_ok, ff_detail = _ffmpeg_ok()
gem_ok, gem_detail = _gemini_key_ok()
overall_ok = ff_ok and gem_ok
detail = "; ".join(d for d in (ff_detail, gem_detail) if d)
result = {"ok": overall_ok, "detail": detail, "ffmpeg": ff_ok, "gemini_api_key": gem_ok}
log.debug("Health probe returning %s", result)
return result
@app.tool()
def search_youtube(query: str, max_results: int = 10) -> dict:
"""Search YouTube for videos.
Args:
query: Free-form search terms, e.g. "lofi focus mix" or
"python packaging tutorial 2024".
max_results: Maximum number of videos to return (1-50). Defaults to 10.
Returns:
A dictionary with a single key ``videos`` that maps to a list of
video objects. Each object contains:
- id: YouTube video ID
- title: Video title
- webpage_url: Canonical video URL
- duration_seconds: Video length in seconds (may be null)
- channel: Channel name
- channel_id: Channel ID
Typical `Aileen Agent` usage:
- Use this tool when the user describes what they want to analyze but does not give a concrete URL.
Notes for LLM tool users:
- Use this tool to retrieve candidate videos before choosing one to
watch or share; it does not download media.
- Keep ``max_results`` modest (<=10) for fastest responses.
"""
from yt_dlp import YoutubeDL # local import so health probe stays light
capped_results = max(1, min(max_results, 50))
opts = {
"quiet": True,
"no_warnings": True,
"noprogress": True,
"skip_download": True,
"extract_flat": "in_playlist",
"logger": _YDLLogger(),
"extractor_args": {"youtube": {"player_client": ["default"]}},
}
search_spec = f"ytsearch{capped_results}:{query}"
log.info("search_youtube query=%r max_results=%d", query, capped_results)
with _silence_stdio():
with YoutubeDL(opts) as ydl:
info = ydl.extract_info(search_spec, download=False)
entries = info.get("entries", []) if info else []
videos = []
for entry in entries:
video_id = entry.get("id")
webpage_url = entry.get("webpage_url") or (
f"https://www.youtube.com/watch?v={video_id}" if video_id else None
)
videos.append(
{
"id": video_id,
"title": entry.get("title"),
"webpage_url": webpage_url,
"duration_seconds": entry.get("duration"),
"channel": entry.get("channel"),
"channel_id": entry.get("channel_id"),
}
)
return {"videos": videos}
# Register media analysis tools:
# - start_media_retrieval / get_media_retrieval_status
# - start_slide_extraction / get_extracted_slides
# - start_media_analysis / get_media_analysis_result
# - start_media_transcription / get_media_transcription_result
#
# Each of these is exposed as an MCP tool that can be called from
# Claude Desktop, the Aileen 3 Agent, or the Gradio demo.
register_media_tools(app)
return app
def main() -> None:
"""Configure logging and run the MCP server over stdio."""
configure_logging()
app = make_app()
app.run() # stdio transport by default
if __name__ == "__main__":
main()