diff --git a/README.md b/README.md index 4ed52213..746513ac 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ pip install paper-search-mcp "mcpServers": { "paper-search-mcp": { "command": "python", - "args": ["-m", "paper_search_mcp.server"], + "args": ["-m", "paper_search_mcp.mcp"], "env": { "PAPER_SEARCH_MCP_UNPAYWALL_EMAIL": "your@email.com", "PAPER_SEARCH_MCP_CORE_API_KEY": "", @@ -419,7 +419,7 @@ git clone https://github.com/openags/paper-search-mcp.git cd paper-search-mcp # 3. Verify it runs (uv auto-resolves dependencies, no manual install needed) -uv run -m paper_search_mcp.server +uv run -m paper_search_mcp.mcp ``` **Claude Desktop config** (replace the directory path with your actual clone location): @@ -432,7 +432,7 @@ uv run -m paper_search_mcp.server "args": [ "run", "--directory", "/path/to/paper-search-mcp", - "-m", "paper_search_mcp.server" + "-m", "paper_search_mcp.mcp" ], "env": { "PAPER_SEARCH_MCP_UNPAYWALL_EMAIL": "your@email.com", @@ -451,7 +451,7 @@ uv run -m paper_search_mcp.server For example, if you cloned to `/Users/mac/Pengsong/paper-search-mcp`: ```json -"args": ["run", "--directory", "/Users/mac/Pengsong/paper-search-mcp", "-m", "paper_search_mcp.server"] +"args": ["run", "--directory", "/Users/mac/Pengsong/paper-search-mcp", "-m", "paper_search_mcp.mcp"] ``` > `uv run` automatically installs dependencies into an isolated environment on first run — no `pip install` or `venv` needed. diff --git a/claude-code/SKILL.md b/claude-code/SKILL.md index 581bcdad..7fc49bd0 100644 --- a/claude-code/SKILL.md +++ b/claude-code/SKILL.md @@ -41,6 +41,24 @@ uv run --directory paper-search read [-o ./downl uv run --directory paper-search sources ``` +### Tool + +The `tool` subcommand exposes API functions. Use it when you need source-specific functions or parameters that the regular top-level CLI does not expose. Positional API arguments stay positional, and optional arguments become kebab-case flags. + +```bash +# List all shared API functions exposed through the tool subcommand +uv run --directory paper-search tool -h + +# Inspect the arguments for one specific shared API function +uv run --directory paper-search tool search_crossref -h + +# Crossref-specific filters and sorting +uv run --directory paper-search tool search_crossref "transformer attention" --filter from-pub-date:2024-01-01,has-full-text:true --sort published --order desc --max-results 2 + +# Direct DOI lookup +uv run --directory paper-search tool get_crossref_paper_by_doi 10.1038/nature12373 +``` + ## Output `search` and `download` return JSON. `read` returns plain text. Config warnings go to stderr and can be ignored. diff --git a/paper_search_mcp/server.py b/paper_search_mcp/api.py similarity index 96% rename from paper_search_mcp/server.py rename to paper_search_mcp/api.py index 194470e6..2b7189ea 100644 --- a/paper_search_mcp/server.py +++ b/paper_search_mcp/api.py @@ -1,11 +1,10 @@ -# paper_search_mcp/server.py +# paper_search_mcp/api.py from typing import List, Dict, Optional, Any import asyncio import os import logging import re import httpx -from mcp.server.fastmcp import FastMCP from .config import get_env from .academic_platforms.arxiv import ArxivSearcher from .academic_platforms.pubmed import PubMedSearcher @@ -34,8 +33,6 @@ # from .academic_platforms.hub import SciHubSearcher from .paper import Paper -# Initialize MCP server -mcp = FastMCP("paper_search_server") logger = logging.getLogger(__name__) # Instances of searchers @@ -239,7 +236,6 @@ async def _try_repository_fallback(doi: str, title: str, save_path: str) -> tupl return None, "; ".join(repository_errors) -@mcp.tool() async def search_papers( query: str, max_results_per_source: int = 5, @@ -355,7 +351,6 @@ async def search_papers( # Tool definitions -@mcp.tool() async def search_arxiv(query: str, max_results: int = 10, sort_by: str = 'relevance', sort_order: str = 'descending') -> List[Dict]: """Search academic papers from arXiv. @@ -371,7 +366,6 @@ async def search_arxiv(query: str, max_results: int = 10, sort_by: str = 'releva return papers if papers else [] -@mcp.tool() async def search_pubmed(query: str, max_results: int = 10, sort: str = 'relevance') -> List[Dict]: """Search academic papers from PubMed. @@ -386,7 +380,6 @@ async def search_pubmed(query: str, max_results: int = 10, sort: str = 'relevanc return papers if papers else [] -@mcp.tool() async def search_biorxiv(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from bioRxiv. @@ -404,7 +397,6 @@ async def search_biorxiv(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_medrxiv(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from medRxiv. @@ -422,7 +414,6 @@ async def search_medrxiv(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_google_scholar(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from Google Scholar. @@ -436,7 +427,6 @@ async def search_google_scholar(query: str, max_results: int = 10) -> List[Dict] return papers if papers else [] -@mcp.tool() async def search_iacr( query: str, max_results: int = 10, fetch_details: bool = True ) -> List[Dict]: @@ -453,7 +443,6 @@ async def search_iacr( return [paper.to_dict() for paper in papers] if papers else [] -@mcp.tool() async def download_arxiv(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF of an arXiv paper. @@ -466,7 +455,6 @@ async def download_arxiv(paper_id: str, save_path: str = "./downloads") -> str: return await asyncio.to_thread(arxiv_searcher.download_pdf, paper_id, save_path) -@mcp.tool() async def download_pubmed(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to download PDF of a PubMed paper. @@ -482,7 +470,6 @@ async def download_pubmed(paper_id: str, save_path: str = "./downloads") -> str: return str(e) -@mcp.tool() async def download_biorxiv(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF of a bioRxiv paper. @@ -495,7 +482,6 @@ async def download_biorxiv(paper_id: str, save_path: str = "./downloads") -> str return biorxiv_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def download_medrxiv(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF of a medRxiv paper. @@ -508,7 +494,6 @@ async def download_medrxiv(paper_id: str, save_path: str = "./downloads") -> str return medrxiv_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def download_iacr(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF of an IACR ePrint paper. @@ -521,7 +506,6 @@ async def download_iacr(paper_id: str, save_path: str = "./downloads") -> str: return iacr_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_arxiv_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from an arXiv paper PDF. @@ -538,7 +522,6 @@ async def read_arxiv_paper(paper_id: str, save_path: str = "./downloads") -> str return "" -@mcp.tool() async def read_pubmed_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a PubMed paper. @@ -551,7 +534,6 @@ async def read_pubmed_paper(paper_id: str, save_path: str = "./downloads") -> st return pubmed_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def read_biorxiv_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a bioRxiv paper PDF. @@ -568,7 +550,6 @@ async def read_biorxiv_paper(paper_id: str, save_path: str = "./downloads") -> s return "" -@mcp.tool() async def read_medrxiv_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a medRxiv paper PDF. @@ -585,7 +566,6 @@ async def read_medrxiv_paper(paper_id: str, save_path: str = "./downloads") -> s return "" -@mcp.tool() async def read_iacr_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from an IACR ePrint paper PDF. @@ -602,7 +582,6 @@ async def read_iacr_paper(paper_id: str, save_path: str = "./downloads") -> str: return "" -@mcp.tool() async def search_semantic(query: str, year: Optional[str] = None, max_results: int = 10) -> List[Dict]: """Search academic papers from Semantic Scholar. @@ -620,7 +599,6 @@ async def search_semantic(query: str, year: Optional[str] = None, max_results: i return papers if papers else [] -@mcp.tool() async def download_semantic(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF of a Semantic Scholar paper. @@ -641,7 +619,6 @@ async def download_semantic(paper_id: str, save_path: str = "./downloads") -> st return semantic_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_semantic_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a Semantic Scholar paper. @@ -666,7 +643,6 @@ async def read_semantic_paper(paper_id: str, save_path: str = "./downloads") -> return "" -@mcp.tool() async def search_crossref( query: str, max_results: int = 10, @@ -695,7 +671,6 @@ async def search_crossref( return papers if papers else [] -@mcp.tool() async def get_crossref_paper_by_doi(doi: str) -> Dict: """Get a specific paper from CrossRef by its DOI. @@ -711,7 +686,6 @@ async def get_crossref_paper_by_doi(doi: str) -> Dict: return paper.to_dict() if paper else {} -@mcp.tool() async def download_crossref(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to download PDF of a CrossRef paper. @@ -731,7 +705,6 @@ async def download_crossref(paper_id: str, save_path: str = "./downloads") -> st return str(e) -@mcp.tool() async def download_scihub( identifier: str, save_path: str = "./downloads", @@ -753,7 +726,6 @@ async def download_scihub( return "Sci-Hub download failed. Try DOI first, then title, or change mirror URL." -@mcp.tool() async def download_with_fallback( source: str, paper_id: str, @@ -846,7 +818,6 @@ async def download_with_fallback( return "Download failed after OA fallback chain and Sci-Hub fallback. Details: " + " | ".join(attempt_errors) -@mcp.tool() async def read_crossref_paper(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to read and extract text content from a CrossRef paper. @@ -863,7 +834,6 @@ async def read_crossref_paper(paper_id: str, save_path: str = "./downloads") -> return crossref_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def search_openalex(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from OpenAlex. @@ -877,7 +847,6 @@ async def search_openalex(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_pmc(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from PubMed Central (PMC). @@ -891,7 +860,6 @@ async def search_pmc(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_core(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from CORE. @@ -905,7 +873,6 @@ async def search_core(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_europepmc(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from Europe PMC. @@ -919,7 +886,6 @@ async def search_europepmc(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_dblp(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from dblp computer science bibliography. @@ -933,7 +899,6 @@ async def search_dblp(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_openaire(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from OpenAIRE European Open Access infrastructure. @@ -947,7 +912,6 @@ async def search_openaire(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_citeseerx(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from CiteSeerX digital library. @@ -961,7 +925,6 @@ async def search_citeseerx(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_doaj(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from DOAJ (Directory of Open Access Journals). @@ -975,7 +938,6 @@ async def search_doaj(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_base(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from BASE (Bielefeld Academic Search Engine). @@ -989,7 +951,6 @@ async def search_base(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_zenodo(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from Zenodo open repository. @@ -1003,7 +964,6 @@ async def search_zenodo(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_hal(query: str, max_results: int = 10) -> List[Dict]: """Search academic papers from HAL open archive. @@ -1017,7 +977,6 @@ async def search_hal(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_ssrn(query: str, max_results: int = 10) -> List[Dict]: """Search metadata records from SSRN. @@ -1033,7 +992,6 @@ async def search_ssrn(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def search_unpaywall(query: str, max_results: int = 10) -> List[Dict]: """Lookup a DOI via Unpaywall and return OA metadata. @@ -1050,7 +1008,6 @@ async def search_unpaywall(query: str, max_results: int = 10) -> List[Dict]: return papers if papers else [] -@mcp.tool() async def read_dblp_paper(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to read and extract text content from a dblp paper. @@ -1066,7 +1023,6 @@ async def read_dblp_paper(paper_id: str, save_path: str = "./downloads") -> str: return dblp_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_dblp(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from dblp. @@ -1082,7 +1038,6 @@ async def download_dblp(paper_id: str, save_path: str = "./downloads") -> str: return dblp_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_openaire_paper(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to read and extract text content from an OpenAIRE paper. @@ -1095,7 +1050,6 @@ async def read_openaire_paper(paper_id: str, save_path: str = "./downloads") -> return openaire_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_openaire(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from OpenAIRE. @@ -1108,7 +1062,6 @@ async def download_openaire(paper_id: str, save_path: str = "./downloads") -> st return openaire_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_citeseerx_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a CiteSeerX paper. @@ -1121,7 +1074,6 @@ async def read_citeseerx_paper(paper_id: str, save_path: str = "./downloads") -> return citeseerx_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_citeseerx(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from CiteSeerX. @@ -1134,7 +1086,6 @@ async def download_citeseerx(paper_id: str, save_path: str = "./downloads") -> s return citeseerx_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_doaj_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a DOAJ paper. @@ -1147,7 +1098,6 @@ async def read_doaj_paper(paper_id: str, save_path: str = "./downloads") -> str: return doaj_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_doaj(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from DOAJ. @@ -1160,7 +1110,6 @@ async def download_doaj(paper_id: str, save_path: str = "./downloads") -> str: return doaj_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_base_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a BASE paper. @@ -1173,7 +1122,6 @@ async def read_base_paper(paper_id: str, save_path: str = "./downloads") -> str: return base_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_base(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from BASE. @@ -1186,7 +1134,6 @@ async def download_base(paper_id: str, save_path: str = "./downloads") -> str: return base_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_zenodo_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a Zenodo paper. @@ -1199,7 +1146,6 @@ async def read_zenodo_paper(paper_id: str, save_path: str = "./downloads") -> st return zenodo_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_zenodo(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from Zenodo. @@ -1212,7 +1158,6 @@ async def download_zenodo(paper_id: str, save_path: str = "./downloads") -> str: return zenodo_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_hal_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read and extract text content from a HAL paper. @@ -1225,7 +1170,6 @@ async def read_hal_paper(paper_id: str, save_path: str = "./downloads") -> str: return hal_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_hal(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from HAL. @@ -1238,7 +1182,6 @@ async def download_hal(paper_id: str, save_path: str = "./downloads") -> str: return hal_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_ssrn_paper(paper_id: str, save_path: str = "./downloads") -> str: """Read paper content from SSRN. @@ -1253,7 +1196,6 @@ async def read_ssrn_paper(paper_id: str, save_path: str = "./downloads") -> str: return ssrn_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_ssrn(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from SSRN. @@ -1268,7 +1210,6 @@ async def download_ssrn(paper_id: str, save_path: str = "./downloads") -> str: return ssrn_searcher.download_pdf(paper_id, save_path) -@mcp.tool() async def read_openalex_paper(paper_id: str, save_path: str = "./downloads") -> str: """Attempt to read and extract text content from an OpenAlex paper. @@ -1281,7 +1222,6 @@ async def read_openalex_paper(paper_id: str, save_path: str = "./downloads") -> return openalex_searcher.read_paper(paper_id, save_path) -@mcp.tool() async def download_openalex(paper_id: str, save_path: str = "./downloads") -> str: """Download PDF for a paper from OpenAlex. @@ -1298,7 +1238,6 @@ async def download_openalex(paper_id: str, save_path: str = "./downloads") -> st # Optional IEEE Xplore tools — registered only when API key is set # --------------------------------------------------------------------------- if ieee_searcher is not None: - @mcp.tool() async def search_ieee(query: str, max_results: int = 10) -> List[Dict]: """Search IEEE Xplore for papers. Requires PAPER_SEARCH_MCP_IEEE_API_KEY (or IEEE_API_KEY). @@ -1310,7 +1249,6 @@ async def search_ieee(query: str, max_results: int = 10) -> List[Dict]: """ return await async_search(ieee_searcher, query, max_results) - @mcp.tool() async def download_ieee(paper_id: str, save_path: str = "./downloads") -> str: """Download a PDF from IEEE Xplore. Requires PAPER_SEARCH_MCP_IEEE_API_KEY (or IEEE_API_KEY) and institutional access. @@ -1322,7 +1260,6 @@ async def download_ieee(paper_id: str, save_path: str = "./downloads") -> str: """ return await asyncio.to_thread(ieee_searcher.download_pdf, paper_id, save_path) - @mcp.tool() async def read_ieee_paper(paper_id: str, save_path: str = "./downloads") -> str: """Download and read an IEEE Xplore paper. Requires PAPER_SEARCH_MCP_IEEE_API_KEY (or IEEE_API_KEY). @@ -1339,7 +1276,6 @@ async def read_ieee_paper(paper_id: str, save_path: str = "./downloads") -> str: # Optional ACM Digital Library tools — registered only when API key is set # --------------------------------------------------------------------------- if acm_searcher is not None: - @mcp.tool() async def search_acm(query: str, max_results: int = 10) -> List[Dict]: """Search ACM Digital Library for papers. Requires PAPER_SEARCH_MCP_ACM_API_KEY (or ACM_API_KEY). @@ -1351,7 +1287,6 @@ async def search_acm(query: str, max_results: int = 10) -> List[Dict]: """ return await async_search(acm_searcher, query, max_results) - @mcp.tool() async def download_acm(paper_id: str, save_path: str = "./downloads") -> str: """Download a PDF from ACM Digital Library. Requires PAPER_SEARCH_MCP_ACM_API_KEY (or ACM_API_KEY) and institutional access. @@ -1363,7 +1298,6 @@ async def download_acm(paper_id: str, save_path: str = "./downloads") -> str: """ return await asyncio.to_thread(acm_searcher.download_pdf, paper_id, save_path) - @mcp.tool() async def read_acm_paper(paper_id: str, save_path: str = "./downloads") -> str: """Download and read an ACM Digital Library paper. Requires PAPER_SEARCH_MCP_ACM_API_KEY (or ACM_API_KEY). @@ -1376,9 +1310,74 @@ async def read_acm_paper(paper_id: str, save_path: str = "./downloads") -> str: return acm_searcher.read_paper(paper_id, save_path) -def main(): - mcp.run(transport="stdio") +async def sources() -> Dict[str, List[str]]: + """List available paper sources for this installation.""" + return {"sources": ALL_SOURCES} + + +TOOLS = [ + search_papers, + search_arxiv, + search_pubmed, + search_biorxiv, + search_medrxiv, + search_google_scholar, + search_iacr, + search_semantic, + search_crossref, + get_crossref_paper_by_doi, + search_openalex, + search_pmc, + search_core, + search_europepmc, + search_dblp, + search_openaire, + search_citeseerx, + search_doaj, + search_base, + search_zenodo, + search_hal, + search_ssrn, + search_unpaywall, + download_arxiv, + download_pubmed, + download_biorxiv, + download_medrxiv, + download_iacr, + download_semantic, + download_crossref, + download_scihub, + download_with_fallback, + download_dblp, + download_openaire, + download_citeseerx, + download_doaj, + download_base, + download_zenodo, + download_hal, + download_ssrn, + download_openalex, + read_arxiv_paper, + read_pubmed_paper, + read_biorxiv_paper, + read_medrxiv_paper, + read_iacr_paper, + read_semantic_paper, + read_crossref_paper, + read_dblp_paper, + read_openaire_paper, + read_citeseerx_paper, + read_doaj_paper, + read_base_paper, + read_zenodo_paper, + read_hal_paper, + read_ssrn_paper, + read_openalex_paper, + sources, +] +if ieee_searcher is not None: + TOOLS.extend([search_ieee, download_ieee, read_ieee_paper]) -if __name__ == "__main__": - main() +if acm_searcher is not None: + TOOLS.extend([search_acm, download_acm, read_acm_paper]) diff --git a/paper_search_mcp/cli.py b/paper_search_mcp/cli.py index 6edc6eb5..83dee855 100644 --- a/paper_search_mcp/cli.py +++ b/paper_search_mcp/cli.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List from .config import get_env +from .tool_cli import add_tool_commands, cmd_tool from .academic_platforms.arxiv import ArxivSearcher from .academic_platforms.pubmed import PubMedSearcher from .academic_platforms.biorxiv import BioRxivSearcher @@ -256,6 +257,10 @@ def build_parser() -> argparse.ArgumentParser: # sources sub.add_parser("sources", help="List available sources") + # tool + tool_parser = sub.add_parser("tool", help="Run a shared API command") + add_tool_commands(tool_parser) + return parser @@ -268,6 +273,7 @@ def main() -> None: "download": cmd_download, "read": cmd_read, "sources": cmd_sources, + "tool": cmd_tool, } exit_code = asyncio.run(dispatch[args.command](args)) diff --git a/paper_search_mcp/mcp.py b/paper_search_mcp/mcp.py new file mode 100644 index 00000000..bad3944e --- /dev/null +++ b/paper_search_mcp/mcp.py @@ -0,0 +1,20 @@ +"""MCP bootstrap for the shared paper search API.""" + +from mcp.server.fastmcp import FastMCP + +from .api import TOOLS + + +mcp = FastMCP("paper_search_server") + +for tool in TOOLS: + mcp.tool()(tool) + + +def main() -> None: + """Run the MCP server over stdio.""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/paper_search_mcp/tool_cli.py b/paper_search_mcp/tool_cli.py new file mode 100644 index 00000000..fdf6f623 --- /dev/null +++ b/paper_search_mcp/tool_cli.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +import inspect +import json +from typing import Any, get_args, get_origin + +from .api import TOOLS + + +def _argument_type(annotation: Any) -> Any: + if annotation is inspect._empty: + return str + origin = get_origin(annotation) + if origin is None: + return bool if annotation is bool else annotation + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) == 1 and args[0] in (str, int, float, bool): + return args[0] + return str + + +def _add_tool_argument(parser: argparse.ArgumentParser, name: str, parameter: inspect.Parameter) -> None: + arg_type = _argument_type(parameter.annotation) + if parameter.default is inspect._empty: + parser.add_argument(name, type=arg_type) + return + option = f"--{name.replace('_', '-')}" + if arg_type is bool: + action = "store_false" if parameter.default else "store_true" + parser.add_argument(option, action=action, default=parameter.default) + return + parser.add_argument(option, type=arg_type, default=parameter.default) + + +def _add_tool_command(subparsers: Any, func: Any) -> None: + doc = inspect.getdoc(func) or "" + parser = subparsers.add_parser( + func.__name__, + help=doc.splitlines()[0] if doc else func.__name__, + description=doc, + ) + for name, parameter in inspect.signature(func).parameters.items(): + _add_tool_argument(parser, name, parameter) + parser.set_defaults(tool_handler=func) + + +def add_tool_commands(tool_parser: argparse.ArgumentParser) -> None: + tool_subparsers = tool_parser.add_subparsers(dest="tool_name", required=True) + for tool in TOOLS: + _add_tool_command(tool_subparsers, tool) + + +async def cmd_tool(args: argparse.Namespace) -> int: + kwargs = { + key: value + for key, value in vars(args).items() + if key not in {"command", "tool_name", "tool_handler"} + } + try: + result = await args.tool_handler(**kwargs) + if isinstance(result, str): + print(result) + else: + print(json.dumps(result, indent=2, default=str)) + return 0 + except Exception as e: + print(json.dumps({"status": "error", "message": str(e)})) + return 1 diff --git a/pyproject.toml b/pyproject.toml index 8bb7eedc..4b784588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ Repository = "https://github.com/openags/paper-search-mcp" Issues = "https://github.com/openags/paper-search-mcp/issues" [project.scripts] -paper-search-mcp = "paper_search_mcp.server:main" +paper-search-mcp = "paper_search_mcp.mcp:main" paper-search = "paper_search_mcp.cli:main" [tool.hatch.build.targets.wheel] diff --git a/tests/test_acm.py b/tests/test_acm.py index 79f34f2e..95d7b32e 100644 --- a/tests/test_acm.py +++ b/tests/test_acm.py @@ -45,9 +45,9 @@ def test_read_raises_not_implemented_without_key(self): def test_not_in_all_sources_without_key(self): """acm must NOT appear in ALL_SOURCES when the key is absent.""" import importlib - import paper_search_mcp.server as srv_module - importlib.reload(srv_module) - self.assertNotIn("acm", srv_module.ALL_SOURCES) + import paper_search_mcp.api as api_module + importlib.reload(api_module) + self.assertNotIn("acm", api_module.ALL_SOURCES) class TestACMIsConfiguredWithKey(unittest.TestCase): diff --git a/tests/test_server.py b/tests/test_api.py similarity index 61% rename from tests/test_server.py rename to tests/test_api.py index 292bae17..e0399eb8 100644 --- a/tests/test_server.py +++ b/tests/test_api.py @@ -1,28 +1,28 @@ -# tests/test_server.py +# tests/test_api.py import unittest import asyncio import os -from paper_search_mcp import server +from paper_search_mcp import api -class TestPaperSearchServer(unittest.TestCase): +class TestPaperSearchApi(unittest.TestCase): def test_all_sources_include_new_platforms(self): - self.assertIn("dblp", server.ALL_SOURCES) - self.assertIn("openaire", server.ALL_SOURCES) - self.assertIn("citeseerx", server.ALL_SOURCES) - self.assertIn("doaj", server.ALL_SOURCES) - self.assertIn("base", server.ALL_SOURCES) - self.assertIn("zenodo", server.ALL_SOURCES) - self.assertIn("hal", server.ALL_SOURCES) - self.assertIn("ssrn", server.ALL_SOURCES) - self.assertIn("unpaywall", server.ALL_SOURCES) + self.assertIn("dblp", api.ALL_SOURCES) + self.assertIn("openaire", api.ALL_SOURCES) + self.assertIn("citeseerx", api.ALL_SOURCES) + self.assertIn("doaj", api.ALL_SOURCES) + self.assertIn("base", api.ALL_SOURCES) + self.assertIn("zenodo", api.ALL_SOURCES) + self.assertIn("hal", api.ALL_SOURCES) + self.assertIn("ssrn", api.ALL_SOURCES) + self.assertIn("unpaywall", api.ALL_SOURCES) def test_parse_sources_with_new_platforms(self): - parsed = server._parse_sources("dblp,doaj,base,zenodo,hal,ssrn,unpaywall,invalid") + parsed = api._parse_sources("dblp,doaj,base,zenodo,hal,ssrn,unpaywall,invalid") self.assertEqual(parsed, ["dblp", "doaj", "base", "zenodo", "hal", "ssrn", "unpaywall"]) def test_search_arxiv(self): """Test the search_arxiv tool returns 10 results.""" - result = asyncio.run(server.search_arxiv("machine learning", max_results=10)) + result = asyncio.run(api.search_arxiv("machine learning", max_results=10)) self.assertIsInstance(result, list, "Result should be a list") self.assertEqual(len(result), 10, "Should return exactly 10 results") for paper in result: @@ -32,7 +32,7 @@ def test_search_arxiv(self): def test_download_arxiv_from_search(self): """Test downloading 10 arXiv papers based on search results.""" # 先搜索 10 个结果 - search_results = asyncio.run(server.search_arxiv("machine learning", max_results=10)) + search_results = asyncio.run(api.search_arxiv("machine learning", max_results=10)) self.assertEqual(len(search_results), 10, "Search should return 10 results") # 下载目录 @@ -42,10 +42,10 @@ def test_download_arxiv_from_search(self): # 下载每个搜索结果的 PDF for paper in search_results: paper_id = paper['paper_id'] - result = asyncio.run(server.download_arxiv(paper_id, save_path)) + result = asyncio.run(api.download_arxiv(paper_id, save_path)) self.assertIsInstance(result, str, f"Result for {paper_id} should be a file path") self.assertTrue(result.endswith(".pdf"), f"Result for {paper_id} should be a PDF file path") self.assertTrue(os.path.exists(result), f"PDF file for {paper_id} should exist on disk") if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_fallback.py b/tests/test_fallback.py index f62fafac..cec5d562 100644 --- a/tests/test_fallback.py +++ b/tests/test_fallback.py @@ -2,16 +2,16 @@ import asyncio from unittest.mock import patch, AsyncMock -from paper_search_mcp import server +from paper_search_mcp import api class TestDownloadWithFallback(unittest.TestCase): def test_repository_fallback_before_scihub(self): - with patch.object(server.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ - patch("paper_search_mcp.server._try_repository_fallback", new=AsyncMock(return_value=("/tmp/repo.pdf", ""))), \ - patch("paper_search_mcp.server.SciHubFetcher.download_pdf", side_effect=AssertionError("Sci-Hub should not be called")): + with patch.object(api.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ + patch("paper_search_mcp.api._try_repository_fallback", new=AsyncMock(return_value=("/tmp/repo.pdf", ""))), \ + patch("paper_search_mcp.api.SciHubFetcher.download_pdf", side_effect=AssertionError("Sci-Hub should not be called")): result = asyncio.run( - server.download_with_fallback( + api.download_with_fallback( source="arxiv", paper_id="1234.5678", doi="10.1000/test", @@ -22,12 +22,12 @@ def test_repository_fallback_before_scihub(self): self.assertEqual(result, "/tmp/repo.pdf") def test_unpaywall_fallback_after_repositories(self): - with patch.object(server.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ - patch("paper_search_mcp.server._try_repository_fallback", new=AsyncMock(return_value=(None, "repo failed"))), \ - patch.object(server.unpaywall_resolver, "resolve_best_pdf_url", return_value="https://example.org/oa.pdf"), \ - patch("paper_search_mcp.server._download_from_url", new=AsyncMock(return_value="/tmp/unpaywall.pdf")): + with patch.object(api.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ + patch("paper_search_mcp.api._try_repository_fallback", new=AsyncMock(return_value=(None, "repo failed"))), \ + patch.object(api.unpaywall_resolver, "resolve_best_pdf_url", return_value="https://example.org/oa.pdf"), \ + patch("paper_search_mcp.api._download_from_url", new=AsyncMock(return_value="/tmp/unpaywall.pdf")): result = asyncio.run( - server.download_with_fallback( + api.download_with_fallback( source="arxiv", paper_id="1234.5678", doi="10.1000/test", @@ -38,11 +38,11 @@ def test_unpaywall_fallback_after_repositories(self): self.assertEqual(result, "/tmp/unpaywall.pdf") def test_no_scihub_returns_oa_chain_error(self): - with patch.object(server.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ - patch("paper_search_mcp.server._try_repository_fallback", new=AsyncMock(return_value=(None, "repo failed"))), \ - patch.object(server.unpaywall_resolver, "resolve_best_pdf_url", return_value=None): + with patch.object(api.arxiv_searcher, "download_pdf", side_effect=Exception("primary failed")), \ + patch("paper_search_mcp.api._try_repository_fallback", new=AsyncMock(return_value=(None, "repo failed"))), \ + patch.object(api.unpaywall_resolver, "resolve_best_pdf_url", return_value=None): result = asyncio.run( - server.download_with_fallback( + api.download_with_fallback( source="arxiv", paper_id="1234.5678", doi="10.1000/test", diff --git a/tests/test_ieee.py b/tests/test_ieee.py index 61f4d9a7..e8469160 100644 --- a/tests/test_ieee.py +++ b/tests/test_ieee.py @@ -47,9 +47,9 @@ def test_not_in_all_sources_without_key(self): """ieee must NOT appear in ALL_SOURCES when the key is absent.""" # Reload server module with key absent to get a clean ALL_SOURCES import importlib - import paper_search_mcp.server as srv_module - importlib.reload(srv_module) - self.assertNotIn("ieee", srv_module.ALL_SOURCES) + import paper_search_mcp.api as api_module + importlib.reload(api_module) + self.assertNotIn("ieee", api_module.ALL_SOURCES) class TestIEEEIsConfiguredWithKey(unittest.TestCase): diff --git a/tests/test_tool_cli.py b/tests/test_tool_cli.py new file mode 100644 index 00000000..38e9b013 --- /dev/null +++ b/tests/test_tool_cli.py @@ -0,0 +1,30 @@ +import asyncio +import io +import json +import unittest +from contextlib import redirect_stdout + +from paper_search_mcp import api, cli + + +class TestToolCli(unittest.TestCase): + def test_build_parser_accepts_tool_command(self): + parser = cli.build_parser() + args = parser.parse_args(["tool", "search_papers", "query", "--max-results-per-source", "3"]) + self.assertEqual(args.command, "tool") + self.assertEqual(args.tool_name, "search_papers") + self.assertEqual(args.query, "query") + self.assertEqual(args.max_results_per_source, 3) + + def test_cmd_tool_runs_shared_api_function(self): + parser = cli.build_parser() + args = parser.parse_args(["tool", "sources"]) + buffer = io.StringIO() + with redirect_stdout(buffer): + exit_code = asyncio.run(cli.cmd_tool(args)) + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(buffer.getvalue()), {"sources": api.ALL_SOURCES}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_unpaywall_source.py b/tests/test_unpaywall_source.py index 4e73dbd5..1fcd1b19 100644 --- a/tests/test_unpaywall_source.py +++ b/tests/test_unpaywall_source.py @@ -3,19 +3,19 @@ from datetime import datetime from unittest.mock import patch -from paper_search_mcp import server +from paper_search_mcp import api from paper_search_mcp.paper import Paper class TestUnpaywallSearchSource(unittest.TestCase): def test_search_unpaywall_empty_without_access(self): - with patch.object(server.unpaywall_resolver, "has_api_access", return_value=False): - result = asyncio.run(server.search_unpaywall("10.1000/test")) + with patch.object(api.unpaywall_resolver, "has_api_access", return_value=False): + result = asyncio.run(api.search_unpaywall("10.1000/test")) self.assertEqual(result, []) def test_search_unpaywall_empty_without_doi(self): - with patch.object(server.unpaywall_resolver, "has_api_access", return_value=True): - result = asyncio.run(server.search_unpaywall("machine learning")) + with patch.object(api.unpaywall_resolver, "has_api_access", return_value=True): + result = asyncio.run(api.search_unpaywall("machine learning")) self.assertEqual(result, []) def test_search_unpaywall_returns_one_record(self): @@ -31,9 +31,9 @@ def test_search_unpaywall_returns_one_record(self): source="unpaywall", ) - with patch.object(server.unpaywall_resolver, "has_api_access", return_value=True), \ - patch.object(server.unpaywall_resolver, "get_paper_by_doi", return_value=paper): - result = asyncio.run(server.search_unpaywall("doi:10.1000/test")) + with patch.object(api.unpaywall_resolver, "has_api_access", return_value=True), \ + patch.object(api.unpaywall_resolver, "get_paper_by_doi", return_value=paper): + result = asyncio.run(api.search_unpaywall("doi:10.1000/test")) self.assertEqual(len(result), 1) self.assertEqual(result[0]["source"], "unpaywall")