| |
| from mcp.server.fastmcp import FastMCP |
| import httpx |
| from bs4 import BeautifulSoup |
| from urllib.parse import urlparse |
| import logging |
| import sys |
| from pathlib import Path |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from shared import ( |
| config, |
| clean_whitespace, |
| truncate_text |
| ) |
| from shared.http_client import get_http_client |
|
|
| |
| logging.basicConfig(level=logging.INFO, force=True) |
| logger = logging.getLogger(__name__) |
|
|
| mcp = FastMCP("fetch-server") |
|
|
| def validate_url(url: str) -> tuple[bool, str]: |
| """Validate URL for security concerns. Returns (is_valid, error_message)""" |
| try: |
| parsed = urlparse(url) |
|
|
| |
| if parsed.scheme not in config.security.allowed_schemes: |
| return False, f"Invalid URL scheme. Only {', '.join(config.security.allowed_schemes)} are allowed." |
|
|
| |
| hostname = parsed.hostname |
| if not hostname: |
| return False, "Invalid URL: no hostname found." |
|
|
| |
| if config.security.is_private_ip(hostname): |
| return False, "Access to localhost/private IPs is not allowed." |
|
|
| return True, "" |
|
|
| except Exception as e: |
| return False, f"Invalid URL: {str(e)}" |
|
|
| def parse_clinical_trial_page(soup: BeautifulSoup, url: str) -> str: |
| """Parse ClinicalTrials.gov trial detail page for structured data.""" |
| |
| if "clinicaltrials.gov" not in url.lower(): |
| return None |
|
|
| |
| import re |
| nct_match = re.search(r'NCT\d{8}', url) |
| nct_id = nct_match.group() if nct_match else "Unknown" |
|
|
| |
| trial_info = [] |
| trial_info.append(f"**NCT ID:** {nct_id}") |
| trial_info.append(f"**URL:** {url}") |
|
|
| |
| title = soup.find('h1') |
| if title: |
| trial_info.append(f"**Title:** {title.get_text(strip=True)}") |
|
|
| |
| status_patterns = [ |
| soup.find('span', string=re.compile(r'Recruiting|Active|Completed|Enrolling', re.I)), |
| soup.find('div', string=re.compile(r'Recruitment Status', re.I)) |
| ] |
| for pattern in status_patterns: |
| if pattern: |
| status_text = pattern.get_text(strip=True) if hasattr(pattern, 'get_text') else str(pattern) |
| trial_info.append(f"**Status:** {status_text}") |
| break |
|
|
| |
| desc_section = soup.find('div', {'class': re.compile('description', re.I)}) |
| if desc_section: |
| desc_text = desc_section.get_text(strip=True)[:500] |
| trial_info.append(f"**Description:** {desc_text}...") |
|
|
| |
| conditions = soup.find_all(string=re.compile(r'Condition', re.I)) |
| if conditions: |
| for cond in conditions[:1]: |
| parent = cond.parent |
| if parent: |
| trial_info.append(f"**Condition:** {parent.get_text(strip=True)[:200]}") |
| break |
|
|
| |
| interventions = soup.find_all(string=re.compile(r'Intervention', re.I)) |
| if interventions: |
| for inter in interventions[:1]: |
| parent = inter.parent |
| if parent: |
| trial_info.append(f"**Intervention:** {parent.get_text(strip=True)[:200]}") |
| break |
|
|
| |
| sponsor = soup.find(string=re.compile(r'Sponsor', re.I)) |
| if sponsor and sponsor.parent: |
| trial_info.append(f"**Sponsor:** {sponsor.parent.get_text(strip=True)[:100]}") |
|
|
| |
| locations = soup.find_all(string=re.compile(r'Location|Site', re.I)) |
| if locations: |
| location_texts = [] |
| for loc in locations[:3]: |
| if loc.parent: |
| location_texts.append(loc.parent.get_text(strip=True)[:50]) |
| if location_texts: |
| trial_info.append(f"**Locations:** {', '.join(location_texts)}") |
|
|
| if len(trial_info) > 2: |
| return "\n\n".join(trial_info) + "\n\n**Note:** This is extracted from the trial webpage. Some details may be incomplete due to page structure variations." |
|
|
| return None |
|
|
| @mcp.tool() |
| async def fetch_url(url: str, extract_text_only: bool = True) -> str: |
| """Fetch content from a URL (paper abstract page, news article, etc.). |
| |
| Args: |
| url: URL to fetch |
| extract_text_only: Extract only main text content (default: True) |
| """ |
| try: |
| logger.info(f"Fetching URL: {url}") |
|
|
| |
| is_valid, error_msg = validate_url(url) |
| if not is_valid: |
| logger.warning(f"URL validation failed: {error_msg}") |
| return f"Error: {error_msg}" |
|
|
| |
| client = get_http_client(timeout=config.api.timeout) |
| response = await client.get(url, headers={ |
| "User-Agent": config.api.user_agent |
| }) |
| response.raise_for_status() |
|
|
| |
| content_length = response.headers.get('content-length') |
| if content_length and int(content_length) > config.content_limits.max_content_size: |
| logger.warning(f"Content too large: {content_length} bytes") |
| return f"Error: Content size ({content_length} bytes) exceeds maximum allowed size of {config.content_limits.max_content_size} bytes" |
|
|
| |
| if len(response.content) > config.content_limits.max_content_size: |
| logger.warning(f"Content too large: {len(response.content)} bytes") |
| return f"Error: Content size exceeds maximum allowed size of {config.content_limits.max_content_size} bytes" |
|
|
| if extract_text_only: |
| soup = BeautifulSoup(response.text, 'html.parser') |
|
|
| |
| trial_data = parse_clinical_trial_page(soup, url) |
| if trial_data: |
| logger.info(f"Successfully parsed clinical trial page: {url}") |
| return trial_data |
|
|
| |
| |
| for script in soup(["script", "style", "meta", "link"]): |
| script.decompose() |
|
|
| |
| text = soup.get_text() |
|
|
| |
| text = clean_whitespace(text) |
|
|
| |
| text = truncate_text(text, max_chars=config.content_limits.max_text_chars) |
|
|
| logger.info(f"Successfully fetched and extracted text from {url}") |
| return text |
| else: |
| |
| html = truncate_text(response.text, max_chars=config.content_limits.max_text_chars) |
|
|
| logger.info(f"Successfully fetched raw HTML from {url}") |
| return html |
|
|
| except httpx.TimeoutException: |
| logger.error(f"Request to {url} timed out") |
| return f"Error: Request timed out after {config.api.timeout} seconds" |
| except httpx.HTTPStatusError as e: |
| logger.error(f"HTTP error fetching {url}: {e}") |
| return f"Error: HTTP {e.response.status_code} - {e.response.reason_phrase}" |
| except httpx.RequestError as e: |
| logger.error(f"Request error fetching {url}: {e}") |
| return f"Error: Failed to fetch URL - {str(e)}" |
| except Exception as e: |
| logger.error(f"Unexpected error fetching {url}: {e}") |
| return f"Error: {str(e)}" |
|
|
| if __name__ == "__main__": |
| mcp.run(transport="stdio") |
|
|