Spaces:
Paused
Paused
| """MCP servers over research paper corpora — one streamable-HTTP endpoint per corpus.""" | |
| import contextlib | |
| import logging | |
| import threading | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor | |
| from datetime import date, timedelta | |
| from pathlib import Path | |
| from mcp.server.fastmcp import FastMCP | |
| from mcp.server.transport_security import TransportSecuritySettings | |
| from sentence_transformers import SentenceTransformer | |
| from starlette.applications import Starlette | |
| from starlette.requests import Request | |
| from starlette.responses import PlainTextResponse | |
| from starlette.routing import Mount, Route | |
| from papers_mcp.corpus import Corpus, Paper | |
| from papers_mcp.search import EMBEDDING_MODEL, SearchIndex | |
| CORPORA = { | |
| "lipsync": "https://github.com/will-rice/lipsync-papers", | |
| "tts": "https://github.com/will-rice/tts-papers", | |
| } | |
| DATA_DIR = Path("data") | |
| REFRESH_INTERVAL_SECONDS = 6 * 60 * 60 | |
| MAX_SEARCH_LIMIT = 50 | |
| MAX_RECENT_DAYS = 365 | |
| corpora: dict[str, Corpus] = {} | |
| indexes: dict[str, SearchIndex] = {} | |
| def create_app() -> Starlette: | |
| """Build the Starlette app mounting one MCP server per corpus (uvicorn factory).""" | |
| logging.basicConfig(level=logging.INFO) | |
| servers = {name: make_server(name) for name in CORPORA} | |
| async def lifespan(app: Starlette): | |
| model = SentenceTransformer(EMBEDDING_MODEL, device="cpu") | |
| with ThreadPoolExecutor() as pool: | |
| list(pool.map(lambda name: build_corpus(name, model), CORPORA)) | |
| threading.Thread(target=refresh_loop, args=(model,), daemon=True).start() | |
| async with contextlib.AsyncExitStack() as stack: | |
| for server in servers.values(): | |
| await stack.enter_async_context(server.session_manager.run()) | |
| yield | |
| async def index_page(request: Request) -> PlainTextResponse: | |
| lines = ["papers-mcp — MCP servers over research paper corpora", ""] | |
| lines += [f" {CORPORA[name]} → /{name}/mcp" for name in CORPORA] | |
| return PlainTextResponse("\n".join(lines)) | |
| routes: list[Mount | Route] = [Route("/", index_page)] | |
| routes += [Mount(f"/{name}", server.streamable_http_app()) for name, server in servers.items()] | |
| return Starlette(routes=routes, lifespan=lifespan) | |
| def make_server(name: str) -> FastMCP: | |
| """Create the FastMCP server (and its four tools) for one corpus.""" | |
| import os | |
| enable_dns_rebinding_protection = os.getenv("MCP_ENABLE_DNS_REBINDING_PROTECTION", "0") == "1" | |
| mcp = FastMCP( | |
| name=f"{name}-papers", | |
| instructions=( | |
| f"Query the {name}-papers research corpus ({CORPORA[name]}): " | |
| "search titles/abstracts, read full papers as markdown, follow the " | |
| "in-corpus citation graph, and list recent papers." | |
| ), | |
| stateless_http=True, | |
| json_response=True, | |
| # This server is mounted into a Starlette app (not run standalone via | |
| # mcp.run()), so FastMCP's own Host-header DNS-rebinding heuristic -- | |
| # which only ever allowlists 127.0.0.1/localhost -- would 421 every | |
| # request once deployed under a real hostname. Access control belongs | |
| # at the reverse-proxy/deployment layer instead. | |
| transport_security=TransportSecuritySettings( | |
| enable_dns_rebinding_protection=enable_dns_rebinding_protection | |
| ), | |
| ) | |
| def search_papers(query: str, limit: int = 10) -> str: | |
| """Hybrid keyword + semantic search over paper titles, abstracts, and authors. | |
| Returns the top matches with paper id, title, authors, submission date, | |
| and abstract. Use the paper id with get_paper or get_citations. | |
| """ | |
| if not query.strip(): | |
| raise ValueError("query must be non-empty") | |
| if not 1 <= limit <= MAX_SEARCH_LIMIT: | |
| raise ValueError(f"limit must be between 1 and {MAX_SEARCH_LIMIT}") | |
| results = indexes[name].search(query, limit) | |
| return "\n\n".join(format_paper(paper) for paper in results) | |
| def get_paper(paper_id: str) -> str: | |
| """Return the paper's full converted markdown (methods, figures, references).""" | |
| paper = lookup(corpora[name].papers, name, paper_id) | |
| if paper.md_path is None: | |
| raise ValueError( | |
| f"{paper_id} has no converted markdown; its metadata and abstract " | |
| "are available via search_papers" | |
| ) | |
| return paper.markdown | |
| def get_citations(paper_id: str) -> str: | |
| """List in-corpus papers this paper cites, and in-corpus papers citing it.""" | |
| # One snapshot: the refresh loop may swap corpora[name] between reads, | |
| # so resolve the paper and its cited titles from the same generation. | |
| papers = corpora[name].papers | |
| paper = lookup(papers, name, paper_id) | |
| def title_list(ids: list[str]) -> str: | |
| if not ids: | |
| return "(none in corpus)" | |
| return "\n".join(f"- {pid}: {papers[pid].title}" for pid in ids) | |
| return ( | |
| f"## Cites ({len(paper.cites)})\n{title_list(paper.cites)}\n\n" | |
| f"## Cited by ({len(paper.cited_by)})\n{title_list(paper.cited_by)}" | |
| ) | |
| def list_recent(days: int = 30) -> str: | |
| """List papers submitted in the last N days, newest first.""" | |
| if not 1 <= days <= MAX_RECENT_DAYS: | |
| raise ValueError(f"days must be between 1 and {MAX_RECENT_DAYS}") | |
| cutoff = (date.today() - timedelta(days=days)).isoformat() | |
| recent = sorted( | |
| (p for p in corpora[name].papers.values() if p.submitted >= cutoff), | |
| key=lambda p: p.submitted, | |
| reverse=True, | |
| ) | |
| if not recent: | |
| return f"No papers submitted in the last {days} days." | |
| return "\n\n".join(format_paper(paper) for paper in recent) | |
| return mcp | |
| def build_corpus(name: str, model: SentenceTransformer) -> None: | |
| """Sync, load, and index one corpus, then swap it into the registry.""" | |
| corpus = Corpus(name=name, repo_url=CORPORA[name], clone_dir=DATA_DIR / f"{name}-papers") | |
| corpus.sync() | |
| corpus.load() | |
| index = SearchIndex(list(corpus.papers.values()), model) | |
| corpora[name] = corpus | |
| indexes[name] = index | |
| def refresh_loop(model: SentenceTransformer) -> None: | |
| """Re-sync and re-index every corpus on an interval; keep the old index on failure.""" | |
| while True: | |
| time.sleep(REFRESH_INTERVAL_SECONDS) | |
| for name in CORPORA: | |
| try: | |
| build_corpus(name, model) | |
| except Exception: | |
| logging.exception("refresh failed for %s; serving previous index", name) | |
| def lookup(papers: dict[str, Paper], name: str, paper_id: str) -> Paper: | |
| """Return the paper for *paper_id* in *papers*, raising a concise error when unknown.""" | |
| paper = papers.get(paper_id) | |
| if paper is None: | |
| raise ValueError(f"paper id {paper_id!r} not found in the {name} corpus") | |
| return paper | |
| def format_paper(paper: Paper) -> str: | |
| """One search/listing hit as compact markdown.""" | |
| return ( | |
| f"**{paper.title}** ({paper.paper_id}, {paper.submitted})\n" | |
| f"{paper.authors}\n" | |
| f"{paper.abstract}" | |
| ) | |