Skip to content
Merged
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
30 changes: 26 additions & 4 deletions ChemCoScientist/download_papers/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from langchain_core.messages import SystemMessage, HumanMessage
from dotenv import load_dotenv
from definitions import CONFIG_PATH
from CoScientist.paper_parser.s3_connection import S3BucketService

from ChemCoScientist.download_papers.prompt import OPENALEX_QUERY_PROMPT

Expand All @@ -22,6 +23,13 @@
DOWNLOADED_PAPERS_PATH = os.environ.get("DOWNLOADED_PAPERS_PATH")
OPENALEX_API_KEY = os.environ.get("OPENALEX_API_KEY")

s3_service = S3BucketService(
endpoint=os.getenv("ENDPOINT_URL"),
access_key=os.getenv("ACCESS_KEY"),
secret_key=os.getenv("SECRET_KEY"),
bucket_name="chemcoscientist-user-data",
)


def sanitize_filename(name: str) -> str:
"""Remove invalid filename characters from a string."""
Expand Down Expand Up @@ -84,7 +92,11 @@ def generate_openalex_url(query: str) -> Dict[str, Any]:
return res.content


def download_papers(task: str) -> List[str]:
def download_papers(
task: str,
session_id: str = "1",
user_id: str = "1"
) -> List[str]:
"""Search for papers matching a task query and download their PDFs using OpenAlex."""
url = generate_openalex_url(task)
logger.info(f"Generated OpenAlex API request URL: {url}")
Expand All @@ -102,13 +114,23 @@ def download_papers(task: str) -> List[str]:
downloaded_path = download_from_openalex(url, title)
downloaded_paths.append(downloaded_path)
if downloaded_paths:
return {'answer': f'Papers were successfully downloaded: {", ".join(titles)}.',
'metadata': {'papers': downloaded_paths}}
logger.info("Uploading downloaded papers to S3...")
for local_path in downloaded_paths:
s3_service.upload_file_object(
prefix=f"{user_id}/{session_id}/web_search_res/",
source_file_name=os.path.basename(local_path),
file_path=local_path,
)

return {
'answer': f'Papers were successfully downloaded: {"\n".join(titles)}.',
'metadata': {"papers": downloaded_paths}
Comment thread
AsyaOrlova marked this conversation as resolved.
}

if "authors" in url or "sources" in url or "institutions" in url:
id = response.json().get("results", [])[0]["id"]
return {'answer': f'Entity ID: {id}'}

if __name__ == "__main__":
result = download_papers("find papers by Yann LeCun")
result = download_papers("find 3 papers about CRISPR-CAS")
print(result)
25 changes: 25 additions & 0 deletions mcp-servers/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
services:
papers-search-mcp-server:
build:
context: ..
dockerfile: mcp-servers/papers-search-mcp-server/Dockerfile
container_name: papers-search-mcp-server
env_file:
- ./papers-search-mcp-server/.env
environment:
PYTHONUNBUFFERED: "1"
ports:
- "7331:7331"
restart: unless-stopped

chemical-mcp-server:
build:
context: ..
dockerfile: mcp-servers/chemical-mcp-server/Dockerfile
ports:
- "7332:7331"
volumes:
- ./data:/tmp/chemical_mcp_annotated
env_file:
- .env
restart: unless-stopped
7 changes: 7 additions & 0 deletions mcp-servers/papers-search-mcp-server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
OPENALEX_API_KEY='api-key'
OPENALEX_EMAIL='your-email'

ENDPOINT_URL='http://0.0.0.0:9000'
ACCESS_KEY='username'
SECRET_KEY='password'
BUCKET_NAME='bucket-name'
15 changes: 15 additions & 0 deletions mcp-servers/papers-search-mcp-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.11-slim

WORKDIR /app

RUN pip install uv

COPY mcp-servers/papers-search-mcp-server/pyproject.toml mcp-servers/papers-search-mcp-server/uv.lock ./

RUN uv sync --frozen --no-install-project

COPY mcp-servers/papers-search-mcp-server/ ./
COPY CoScientist/paper_parser /app/CoScientist/paper_parser
COPY definitions.py ./

CMD ["uv", "run", "--no-project", "python", "papers_search_server.py"]
78 changes: 78 additions & 0 deletions mcp-servers/papers-search-mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# papers-search-mcp-server

## Environment

Create a `.env` file in this directory based on `.env.example`.

## Server Tools

The server exposes three MCP tools:

### `search_entity`

Finds an OpenAlex entity ID by name.

Main arguments:

- `entity_type`: one of `author`, `source`, `institution`
- `entity_name`: entity name to search for

Use this tool when a paper query depends on an author, journal, or institution filter.

### `search_papers`

Searches OpenAlex works and returns paper metadata.

Main arguments:

- `keywords`: free-text topic or query string
- `author_id`: OpenAlex author ID
- `institution_id`: OpenAlex institution ID
- `source_id`: OpenAlex source ID
- `publication_year`: year or year filter such as `2025` or `>2020`
- `open_access`: whether to keep only open-access papers
- `has_pdf`: whether to keep only papers with an available PDF
- `limit`: maximum number of results
- `sort`: OpenAlex sort field such as `publication_year:desc` or `cited_by_count:desc`

This tool returns a human-readable summary and normalized metadata for the matching papers.

### `download_papers_from_search`

Searches OpenAlex works and uploads matching PDFs directly to S3.

Main arguments:

- All search arguments from `search_papers`
- `session_id`: session identifier used in the S3 prefix
- `user_id`: user identifier used in the S3 prefix

Uploaded files are stored under the S3 prefix `user_id/session_id/web_search_res/`.

## Run With uv

From `ChemCoScientist/mcp/papers_search`:

```bash
set -a
source .env
set +a
uv sync --frozen --no-install-project
uv run --no-project python papers_search_server.py
```

## Run With Docker

Build from `ChemCoScientist/mcp/papers_search`, but use the repository root as the Docker build context:

```bash
docker build -f Dockerfile -t papers-search-mcp-server ../../..
```

Run the container with the environment file and port mapping:

```bash
docker run --rm -i -p 7331:7331 --env-file .env papers-search-mcp-server
```

The server will be available at `http://localhost:7331/mcp`.
154 changes: 154 additions & 0 deletions mcp-servers/papers-search-mcp-server/openalex_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from __future__ import annotations

import time
import logging
import os
from urllib.parse import urljoin
import requests
from dotenv import load_dotenv
from pprint import pprint

DOWNLOADED_PAPERS_PATH = os.environ.get("DOWNLOADED_PAPERS_PATH")
OPENALEX_EMAIL = os.environ.get("OPENALEX_EMAIL")


class OpenAlexClient:
"""Client for interacting with the OpenAlex API."""

BASE_URL = "https://api.openalex.org"

def __init__(
self,
email: str = None,
s3_service: S3BucketService = None,
) -> None:
self.email = email

def request_with_retry(
self,
endpoint: str,
params: dict = None,
max_retries: int = 3,
timeout: int = 30,
stream: bool = False,
) -> requests.Response:
"""Make an HTTP GET request with retry logic for rate limits and server errors."""
url = endpoint if endpoint.startswith("http") else urljoin(self.BASE_URL, endpoint)
for attempt in range(max_retries):
try:
response = requests.get(url, params=params, timeout=timeout, stream=stream)
if response.status_code == 200:
return response
if response.status_code == 403 or response.status_code >= 500:
wait_time = 2 ** attempt
time.sleep(wait_time)
else:
response.raise_for_status()
except requests.exceptions.Timeout:
if attempt < max_retries - 1:
logging.info(f"Retrying... Attempt {attempt + 2}")
time.sleep(2 ** attempt)
else:
raise

raise Exception(f"Failed after {max_retries} retries")

def search_works(
self,
keywords: str = None,
author_id: str = None,
institution_id: str = None,
source_id: str = None,
publication_year: str = None,
open_access: bool = True,
has_pdf: bool = True,
limit: int = 10,
sort: str = None
):
"""
Search for works in OpenAlex based on various filters.

Args:
keywords: Search keywords for title and abstract, e.g. 'machine learning drug discovery'
author_id: Filter by OpenAlex author ID
institution_id: Filter by OpenAlex institution ID
source_id: Filter by OpenAlex source ID
publication_year: Filter by publication year (e.g., ">2020", "2020-2022")
open_access: Whether to filter for open access papers
has_pdf: Whether to filter for papers with PDF available
limit: Number of results to return (max 200)
sort: Sorting criterion (e.g., "publication_date", "cited_by_count:desc")
"""
filters = []

if keywords:
filters.append(f"title.search:{keywords.replace(' ', '+')}")
if author_id:
filters.append(f"author.id:{author_id}")
if institution_id:
filters.append(f"institution.id:{institution_id}")
if source_id:
filters.append(f"primary_location.source.id:{source_id}")
if publication_year:
filters.append(f"publication_year:{publication_year}")
if open_access:
filters.append("is_oa:true")
if has_pdf:
filters.append("has_content.pdf:true")


params = {"filter": ",".join(filters)}

if sort:
params["sort"] = sort

params["per-page"] = limit

return self.request_with_retry(endpoint="works", params=params).json()

def search_entity(
self,
entity_type: str,
entity_name: str
) -> dict:
"""
Search for an entity ID (author, source, institution) by its name in OpenAlex.

Args:
entity_type: Type of entity to search for ("author", "source", "institution")
entity_name: Name of the entity to search for, e.g., author name, journal name,
or institution name
Returns:
Dictionary containing the most relevant search result for the specified entity
"""
endpoint_map = {
"author": "authors",
"source": "sources",
"institution": "institutions"
}
if entity_type not in endpoint_map:
raise ValueError(f"Unsupported entity type: {entity_type}")

params = {"search": entity_name, "per-page": 1}
response = self.request_with_retry(
endpoint=endpoint_map[entity_type],
params=params
).json()
return response.get("results", [])[0]


if __name__ == "__main__":
client = OpenAlexClient(email=OPENALEX_EMAIL)
# Example works search:
result = client.search_works(
institution_id="i173089394", # Replace with a valid institution ID
publication_year="2025",
limit=1,
sort="cited_by_count:desc"
)
# Example entity search:
# result = client.search_entity(
# entity_type="institution",
# entity_name="ITMO University"
# )
pprint(result)
Loading