Spaces:
Paused
Paused
File size: 7,296 Bytes
c88c4a7 3ccdaee c88c4a7 b47245a c88c4a7 b47245a c88c4a7 ede187b c88c4a7 ede187b c88c4a7 ede187b c88c4a7 5f14188 c88c4a7 ede187b c88c4a7 3ccdaee c88c4a7 5f14188 c88c4a7 ede187b c88c4a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | """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}
@contextlib.asynccontextmanager
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
),
)
@mcp.tool()
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)
@mcp.tool()
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
@mcp.tool()
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)}"
)
@mcp.tool()
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}"
)
|