diff --git a/.env.example b/.env.example index 63caab3f..a6e3490a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ # Core optional keys/tokens PAPER_SEARCH_MCP_SEMANTIC_SCHOLAR_API_KEY= +PAPER_SEARCH_MCP_OPENALEX_API_KEY= +PAPER_SEARCH_MCP_OPENALEX_EMAIL= PAPER_SEARCH_MCP_CORE_API_KEY= PAPER_SEARCH_MCP_UNPAYWALL_EMAIL= PAPER_SEARCH_MCP_DOAJ_API_KEY= diff --git a/README.md b/README.md index 73d4158c..3b14341f 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ This matrix reflects **verified live-integration results** from functional and e | IACR | ✅ | ✅ | ✅ | Open API; reliable | | Semantic Scholar | ✅ | ✅ (OA) | ✅ (OA) | Works without key (rate-limited); key improves limits; key rejection (403) retried automatically without key | | Crossref | ✅ | ❌ | ⚠️ info-only | Open API; reliable | -| OpenAlex | ✅ | ❌ | ⚠️ info-only | Open API; reliable | +| OpenAlex | ✅ | ❌ | ⚠️ info-only | Open API; free API key improves daily limits | | PMC | ✅ | ✅ (OA only) | ✅ (OA only) | OA PDFs only; direct download may be blocked by some proxy environments | | CORE | ✅ | ✅ (record-dependent) | ✅ (record-dependent) | Free key recommended; connector retries with backoff and falls back to key-less on 401/403 | | Europe PMC | ✅ | ✅ (OA) | ✅ (OA) | OA PDFs only; direct download may be blocked by some proxy environments | @@ -120,6 +120,8 @@ All keys are **optional** unless noted. Configure them in `~/.config/paper-searc | `PAPER_SEARCH_MCP_UNPAYWALL_EMAIL` | Unpaywall | **Yes** (Unpaywall disabled without it) | Any valid email; register at [unpaywall.org](https://unpaywall.org/products/api) | | `PAPER_SEARCH_MCP_CORE_API_KEY` | CORE | Recommended | Free at [core.ac.uk/services/api](https://core.ac.uk/services/api) | | `PAPER_SEARCH_MCP_SEMANTIC_SCHOLAR_API_KEY` | Semantic Scholar | Optional | Free at [semanticscholar.org](https://www.semanticscholar.org/product/api) — improves rate limits | +| `PAPER_SEARCH_MCP_OPENALEX_API_KEY` | OpenAlex | Optional | Free at [docs.openalex.org](https://docs.openalex.org/how-to-use-the-api/api-keys) - improves daily limits | +| `PAPER_SEARCH_MCP_OPENALEX_EMAIL` | OpenAlex | Optional | Contact email used in the OpenAlex `User-Agent` | | `PAPER_SEARCH_MCP_GOOGLE_SCHOLAR_PROXY_URL` | Google Scholar | Optional | Your HTTP/HTTPS proxy URL — bypasses bot-detection | | `PAPER_SEARCH_MCP_DOAJ_API_KEY` | DOAJ | Optional | Free at [doaj.org](https://doaj.org/apply-for-api-key/) — raises hourly rate limit | | `PAPER_SEARCH_MCP_ZENODO_ACCESS_TOKEN` | Zenodo | Optional | Free at [zenodo.org](https://zenodo.org/account/settings/applications/) — required for private records | @@ -138,6 +140,7 @@ Some search failures are caused by external provider instability, not by bugs in |---|---|---|---| | Google Scholar | Returns 0 results / empty HTML | Bot-detection (CAPTCHA) | Set `PAPER_SEARCH_MCP_GOOGLE_SCHOLAR_PROXY_URL` to a proxy | | Semantic Scholar | 429 rate-limited responses | Anonymous access rate limit | Set `PAPER_SEARCH_MCP_SEMANTIC_SCHOLAR_API_KEY`; if key is rejected (403) connector automatically retries without key | +| OpenAlex | 403/429 or daily quota errors | Anonymous access daily limit | Set `PAPER_SEARCH_MCP_OPENALEX_API_KEY` | | CORE | 500 / timeout errors | Unauthenticated rate limiting | Set `PAPER_SEARCH_MCP_CORE_API_KEY` (free); connector retries with exponential backoff and falls back to key-less on 401/403 | | OpenAIRE | Transient 403 responses | IP-based session rate limiting | Connector retries 3× per profile, escalating: plain session → XML Accept header → raw `requests.get` with Mozilla UA | | CiteSeerX | 404 via web archive redirect | PSU endpoint intermittently redirects to archive | No workaround; connector returns empty gracefully | diff --git a/paper_search_mcp/academic_platforms/openalex.py b/paper_search_mcp/academic_platforms/openalex.py index faa92034..7e44631a 100644 --- a/paper_search_mcp/academic_platforms/openalex.py +++ b/paper_search_mcp/academic_platforms/openalex.py @@ -5,6 +5,7 @@ from ..paper import Paper from .base import PaperSource from ..utils import extract_doi +from ..config import get_env logger = logging.getLogger(__name__) @@ -13,13 +14,22 @@ class OpenAlexSearcher(PaperSource): """OpenAlex paper search implementation""" BASE_URL = "https://api.openalex.org/works" + DEFAULT_USER_AGENT = "paper-search-mcp/1.0" + DEFAULT_EMAIL = "openags@example.com" - def __init__(self): + def __init__(self, api_key: Optional[str] = None, email: Optional[str] = None): self.session = requests.Session() - # OpenAlex encourages providing an email in User-Agent for the "polite pool" - self.session.headers.update( - {"User-Agent": "paper-search-mcp/1.0 (mailto:openags@example.com)"} - ) + self.api_key = ( + api_key if api_key is not None else get_env("OPENALEX_API_KEY", "") + ).strip() + self.email = ( + email if email is not None else get_env("OPENALEX_EMAIL", self.DEFAULT_EMAIL) + ).strip() + + user_agent = self.DEFAULT_USER_AGENT + if self.email: + user_agent = f"{user_agent} (mailto:{self.email})" + self.session.headers.update({"User-Agent": user_agent}) def _reconstruct_abstract(self, inverted_index: dict) -> str: """ @@ -58,6 +68,8 @@ def search(self, query: str, max_results: int = 10) -> List[Paper]: "search": query, "per_page": min(max_results, 200), } + if self.api_key: + params["api_key"] = self.api_key response = self.session.get(self.BASE_URL, params=params, timeout=30) diff --git a/tests/test_openalex.py b/tests/test_openalex.py new file mode 100644 index 00000000..b757a9e3 --- /dev/null +++ b/tests/test_openalex.py @@ -0,0 +1,51 @@ +import os +import unittest +from unittest.mock import Mock, patch + +from paper_search_mcp.academic_platforms.openalex import OpenAlexSearcher + + +class TestOpenAlexSearcher(unittest.TestCase): + def test_search_sends_api_key_from_env(self): + with patch.dict( + os.environ, + { + "PAPER_SEARCH_MCP_ENV_FILE": "/tmp/paper-search-mcp-missing.env", + "PAPER_SEARCH_MCP_OPENALEX_API_KEY": "test-openalex-key", + }, + clear=True, + ): + searcher = OpenAlexSearcher() + + response = Mock(status_code=200) + response.json.return_value = {"results": []} + + with patch.object(searcher.session, "get", return_value=response) as get: + papers = searcher.search("graph neural networks", max_results=7) + + self.assertEqual(papers, []) + params = get.call_args[1]["params"] + self.assertEqual(params["api_key"], "test-openalex-key") + self.assertEqual(params["per_page"], 7) + + def test_search_omits_empty_api_key(self): + searcher = OpenAlexSearcher(api_key="") + response = Mock(status_code=200) + response.json.return_value = {"results": []} + + with patch.object(searcher.session, "get", return_value=response) as get: + searcher.search("protein design") + + self.assertNotIn("api_key", get.call_args[1]["params"]) + + def test_email_customizes_user_agent(self): + searcher = OpenAlexSearcher(email="researcher@example.com") + + self.assertIn( + "mailto:researcher@example.com", + searcher.session.headers["User-Agent"], + ) + + +if __name__ == "__main__": + unittest.main()