Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
22 changes: 17 additions & 5 deletions paper_search_mcp/academic_platforms/openalex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions tests/test_openalex.py
Original file line number Diff line number Diff line change
@@ -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()