research-agent-two / research /fastmcp_server.py
evalstate's picture
evalstate HF Staff
Deploy f215920 archive navigation cache
9ea665e verified
Raw
History Blame Contribute Delete
7.48 kB
"""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),
)