| """FastMCP App boundary for the research agent.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import asyncio |
| from pathlib import Path |
| from typing import Annotated, Any |
|
|
| from fast_agent import FastAgent |
| from fastmcp import Context as MCPContext |
| from fastmcp import FastMCP, FastMCPApp |
| from fastmcp.apps import ResourceCSP |
| from pydantic import Field |
| from prefab_ui.app import PrefabApp |
|
|
| from .app_auth import auth_provider, http_middleware, request_auth |
| from .app_artifacts import read_bucket_markdown |
| from .app_jobs import ( |
| ResearchJobStore, |
| ResearchTaskRegistry, |
| owner_id, |
| unavailable_snapshot, |
| ) |
| from .app_renderer import app_build_id, install_versioned_renderer |
| from .app_ui import build_research_ui |
| from .hf_design import HF_RESOURCE_DOMAINS, HFDesign |
| from .research_runner import ResearchRunner |
|
|
| RESEARCH_HOME = Path(__file__).parent |
| AGENT_CARDS = RESEARCH_HOME / "agent-cards" |
| PRODUCTION_UI_DESIGN: HFDesign = "hub-classic" |
| STATELESS_TRANSPORTS = {"http", "streamable-http"} |
|
|
|
|
| def configure_research_ui_csp(app: FastMCPApp, tool_name: str = "research") -> None: |
| """Allow the Google-hosted Hub fonts used by design variants.""" |
| csp = ResourceCSP(resource_domains=list(HF_RESOURCE_DOMAINS)).model_dump( |
| by_alias=True, |
| exclude_none=True, |
| ) |
| for tool in app._local._components.values(): |
| if getattr(tool, "name", None) == tool_name: |
| tool.meta.setdefault("ui", {})["csp"] = csp |
| return |
| raise RuntimeError(f"Prefab UI tool was not registered: {tool_name}") |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="Serve the research FastMCP App.") |
| parser.add_argument("--host", default="127.0.0.1") |
| parser.add_argument("--port", type=int, default=8724) |
| parser.add_argument( |
| "--transport", |
| choices=("http", "streamable-http", "sse"), |
| default="http", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def use_stateless_http(transport: str) -> bool: |
| """Avoid stale in-memory MCP sessions on restartable HTTP deployments.""" |
| return transport in STATELESS_TRANSPORTS |
|
|
|
|
| def register_research_app( |
| app: FastMCPApp, |
| jobs: ResearchJobStore, |
| runner: ResearchRunner, |
| build_id: str, |
| ) -> None: |
| """Register one UI entry point and its app-only backend tools.""" |
| tasks = ResearchTaskRegistry() |
|
|
| @app.ui( |
| name="research", |
| title="🤗 Research Agent", |
| description=( |
| "Conduct sourced research on Hugging Face. Provide specific, " |
| "goal-focused requests or tasks that state what should be established, " |
| "analysed, reproduced, or compared. State authoritative sources, " |
| "timeframes, and constraints. Produces a comprehensive report with " |
| "references, citations, and reproductions." |
| ), |
| ) |
| async def research( |
| topic: Annotated[ |
| str, |
| Field( |
| description=( |
| "The clearly stated goal of the research or reproduction task. " |
| "Provide important constraints and the preferred approach when " |
| "specified. Work with the user to refine this before calling if needed. " |
| ) |
| ), |
| ], |
| ctx: MCPContext, |
| ) -> PrefabApp: |
| auth = request_auth() |
| job = await jobs.create(topic, owner_id(auth, ctx.session_id)) |
| identity = ( |
| auth.subject or "authenticated user" |
| if auth is not None |
| else "local development user" |
| ) |
| job.add_event(f"Workspace access confirmed for {identity}.", kind="Setup") |
| return build_research_ui( |
| topic, |
| job.snapshot(), |
| build_id=build_id, |
| design=PRODUCTION_UI_DESIGN, |
| ) |
|
|
| configure_research_ui_csp(app) |
|
|
| @app.tool() |
| async def start_research( |
| job_id: str, |
| ctx: MCPContext, |
| ) -> dict[str, Any]: |
| auth = request_auth() |
| result = await jobs.begin(job_id, owner_id(auth, ctx.session_id)) |
| if result is None: |
| return unavailable_snapshot(job_id) |
| if result.started: |
| tasks.start(result.job.id, runner.run(result.job, auth)) |
| return result.job.snapshot() |
|
|
| @app.tool() |
| async def research_status( |
| job_id: str, |
| ctx: MCPContext, |
| ) -> dict[str, Any]: |
| auth = request_auth() |
| job = await jobs.get(job_id, owner_id(auth, ctx.session_id)) |
| return job.snapshot() if job else unavailable_snapshot(job_id) |
|
|
| @app.tool() |
| async def cancel_research( |
| job_id: str, |
| ctx: MCPContext, |
| ) -> dict[str, Any]: |
| auth = request_auth() |
| result = await jobs.cancel(job_id, owner_id(auth, ctx.session_id)) |
| if result is None: |
| return unavailable_snapshot(job_id) |
| if result.cancel_task: |
| tasks.cancel(job_id) |
| return result.job.snapshot() |
|
|
| @app.tool() |
| async def research_chat_context( |
| job_id: str, |
| ctx: MCPContext, |
| ) -> dict[str, str]: |
| auth = request_auth() |
| job = await jobs.get(job_id, owner_id(auth, ctx.session_id)) |
| if job is None or not job.markdown_report_uri: |
| raise RuntimeError("Research Markdown report is not available yet") |
| markdown = await asyncio.to_thread(read_bucket_markdown, job, auth) |
| context_markdown = ( |
| f"{markdown.rstrip()}\n\n---\n\n" |
| f"## Research run metadata\n\n" |
| f"- App build: `{build_id}`\n\n" |
| f"{job.result or ''}" |
| ) |
| return { |
| "markdown": context_markdown, |
| "message": ( |
| "The research report has been added to context. " |
| "Please summarize the main findings and include links to the " |
| "source artifacts and generated Markdown and HTML reports." |
| ), |
| } |
|
|
|
|
| def build_fast_agent() -> FastAgent: |
| """Build the production Harness without model-visible host filesystem access.""" |
| fast = FastAgent( |
| "Research Agent FastMCP App", |
| parse_cli_args=False, |
| home=RESEARCH_HOME, |
| ) |
| fast.load_agents(AGENT_CARDS) |
| return fast |
|
|
|
|
| def enforce_production_isolation(fast: FastAgent) -> None: |
| """Keep model-facing tools off the shared Space host filesystem.""" |
| fast.app.context.no_shell = True |
|
|
|
|
| async def main() -> None: |
| args = parse_args() |
| fast = build_fast_agent() |
|
|
| async with fast.harness() as harness: |
| enforce_production_isolation(fast) |
| build_id = app_build_id(RESEARCH_HOME) |
| app = FastMCPApp("Research Agent") |
| register_research_app( |
| app, |
| jobs=ResearchJobStore(), |
| runner=ResearchRunner(harness, RESEARCH_HOME), |
| build_id=build_id, |
| ) |
|
|
| mcp = FastMCP( |
| "research-agent-app", |
| auth=auth_provider(), |
| instructions="Call `research` to open the live research app.", |
| ) |
| mcp.add_provider(app) |
| install_versioned_renderer( |
| mcp, |
| app_name="Research Agent", |
| tool_name="research", |
| build_id=build_id, |
| ) |
| await mcp.run_http_async( |
| transport=args.transport, |
| host=args.host, |
| port=args.port, |
| middleware=http_middleware(), |
| stateless_http=use_stateless_http(args.transport), |
| ) |
|
|