ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,7 @@
"""
Perception Tools MCP Server
A comprehensive MCP server for perception and data retrieval capabilities.
"""
__version__ = "1.0.0"
@@ -0,0 +1,209 @@
"""
Enhanced ArXiv tools with download and details.
Based on AWorld parxiv-server complete implementation.
"""
import hashlib
import json
import logging
import os
import re
import traceback
from typing import Union
import arxiv
import httpx
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse
load_dotenv()
async def get_paper_details(
paper_id: str
) -> Union[str, TextContent]:
"""
Get detailed information about an ArXiv paper.
Args:
paper_id: ArXiv paper ID (e.g., '2301.07041')
Returns:
TextContent with paper details
"""
try:
clean_id = re.sub(r"^arxiv:", "", paper_id, flags=re.IGNORECASE).strip()
logging.info(f"📄 Getting paper details: {clean_id}")
search = arxiv.Search(id_list=[clean_id])
paper = next(arxiv.Client().results(search), None)
if not paper:
raise ValueError(f"Paper not found: {clean_id}")
result = {
"entry_id": paper.entry_id,
"title": paper.title,
"authors": [author.name for author in paper.authors],
"summary": paper.summary,
"published": paper.published.isoformat(),
"updated": paper.updated.isoformat() if paper.updated else None,
"categories": paper.categories,
"primary_category": paper.primary_category,
"pdf_url": paper.pdf_url,
"doi": paper.doi,
"journal_ref": paper.journal_ref
}
logging.info(f"✅ Retrieved paper: {paper.title}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"paper_id": clean_id}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to get paper details: {str(e)}"
logging.error(f"ArXiv error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "arxiv_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def download_paper(
paper_id: str,
download_dir: str = "."
) -> Union[str, TextContent]:
"""
Download ArXiv paper PDF.
Args:
paper_id: ArXiv paper ID
download_dir: Directory to save PDF
Returns:
TextContent with download result
"""
try:
from pathlib import Path
clean_id = re.sub(r"^arxiv:", "", paper_id, flags=re.IGNORECASE).strip()
logging.info(f"📥 Downloading paper: {clean_id}")
if not re.fullmatch(
r"(?:[a-z-]+(?:\.[A-Z]{2})?/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?",
clean_id,
flags=re.IGNORECASE,
):
raise ValueError(f"Invalid arXiv paper ID: {clean_id}")
# Fetch the canonical PDF directly. Re-querying the Atom metadata API
# for every ID introduces an unrelated failure point and triggers its
# batch-query backoff during the three-paper experiment.
download_path = Path(download_dir)
download_path.mkdir(parents=True, exist_ok=True)
filename = f"{clean_id.replace('/', '_')}.pdf"
file_path = download_path / filename
temporary_path = download_path / f".{filename}.part"
pdf_url = f"https://arxiv.org/pdf/{clean_id}.pdf"
async with httpx.AsyncClient(
timeout=180,
follow_redirects=True,
headers={"User-Agent": "ai-agent-book-experiment/4.6"},
) as client:
response = await client.get(pdf_url)
response.raise_for_status()
content = response.content
if len(content) <= 1000 or not content.startswith(b"%PDF-"):
raise ValueError("arXiv response was not a substantive PDF")
temporary_path.write_bytes(content)
os.replace(temporary_path, file_path)
result = {
"paper_id": clean_id,
"file_path": str(file_path),
"file_size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
"pdf_url": pdf_url,
"content_type": response.headers.get("content-type"),
}
logging.info(f"✅ Downloaded: {len(content)} bytes")
action_response = ActionResponse(
success=True,
message=result,
metadata={"paper_id": clean_id}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Download failed: {str(e)}",
metadata={"error_type": "download_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_arxiv_categories() -> Union[str, TextContent]:
"""
Get list of ArXiv subject categories.
Returns:
TextContent with categories
"""
categories = {
"cs": "Computer Science",
"math": "Mathematics",
"physics": "Physics",
"astro-ph": "Astrophysics",
"cond-mat": "Condensed Matter",
"q-bio": "Quantitative Biology",
"q-fin": "Quantitative Finance",
"stat": "Statistics",
"econ": "Economics",
"eess": "Electrical Engineering"
}
result = {
"categories": categories,
"count": len(categories)
}
action_response = ActionResponse(
success=True,
message=result,
metadata={"total_categories": len(categories)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
+142
View File
@@ -0,0 +1,142 @@
"""
Base models and utilities for perception tools MCP server.
"""
import logging
import os
import tempfile
import traceback
from pathlib import Path
from urllib.parse import urlparse
from typing import Any
import requests
from pydantic import BaseModel, Field
class ActionResponse(BaseModel):
"""Standard response format for all perception tool actions."""
success: bool = Field(default=False, description="Whether the action was successfully executed")
message: Any = Field(default=None, description="The execution result of the action")
metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata about the action")
class DocumentMetadata(BaseModel):
"""Metadata for document processing operations."""
file_name: str = Field(description="Original file name")
file_size: int = Field(description="File size in bytes")
file_type: str = Field(description="Document file type/extension")
absolute_path: str = Field(description="Absolute path to the document file")
page_count: int | None = Field(default=None, description="Number of pages in document")
processing_time: float | None = Field(default=None, description="Time taken to process")
output_format: str = Field(description="Format of the extracted content")
def is_url(path_or_url: str) -> bool:
"""
Check if the given string is a URL.
Args:
path_or_url: String to check
Returns:
True if the string is a URL, False otherwise
"""
parsed = urlparse(path_or_url)
return bool(parsed.scheme and parsed.netloc)
def validate_file_path(file_path: str) -> Path:
"""
Validate and resolve file path.
Args:
file_path: Path to the file
Returns:
Resolved Path object
Raises:
FileNotFoundError: If file doesn't exist
"""
path = Path(file_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
if not path.is_file():
raise ValueError(f"Path is not a file: {path}")
return path
def download_file_from_url(
url: str,
timeout: int = 60,
max_size_mb: float = 100.0
) -> tuple[str, bytes]:
"""
Download file from URL to temporary location.
Args:
url: URL to download from
timeout: Request timeout in seconds
max_size_mb: Maximum file size in MB
Returns:
Tuple of (temp_file_path, content)
Raises:
ValueError: If file size exceeds limit
requests.RequestException: If download fails
"""
max_size_bytes = max_size_mb * 1024 * 1024
# Best-effort size pre-check. Many hosts refuse HEAD (presigned S3/GCS URLs
# sign the verb and return 403; CDN/WAF-fronted endpoints often return 405),
# so a failed HEAD must not abort a download that GET can serve -- the
# streaming loop below enforces max_size_bytes either way.
try:
head_response = requests.head(url, timeout=timeout, allow_redirects=True)
head_response.raise_for_status()
content_length = head_response.headers.get("content-length")
except requests.RequestException:
content_length = None
if content_length and int(content_length) > max_size_bytes:
raise ValueError(
f"File size ({int(content_length) / (1024 * 1024):.2f} MB) "
f"exceeds maximum allowed size ({max_size_mb} MB)"
)
try:
# Download the file
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
# Read content with size checking
content = b""
for chunk in response.iter_content(chunk_size=8192):
if len(content) + len(chunk) > max_size_bytes:
raise ValueError(f"File size exceeds maximum allowed size ({max_size_mb} MB)")
content += chunk
# Create temporary file
parsed_url = urlparse(url)
filename = os.path.basename(parsed_url.path) or "downloaded_file"
suffix = Path(filename).suffix or ".tmp"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_file.write(content)
temp_path = temp_file.name
return temp_path, content
except requests.RequestException as e:
raise requests.RequestException(f"Failed to download file from URL: {e}")
except ValueError:
# Documented in the docstring; must not be rewrapped as IOError.
raise
except Exception as e:
raise IOError(f"Error downloading file: {e}")
@@ -0,0 +1,344 @@
"""
Document processing tools for PDF, DOCX, PPTX, CSV, TXT.
Based on AWorld MCP server implementation.
"""
import json
import logging
import traceback
from pathlib import Path
from typing import Union, Dict, Any
import pandas as pd
from docx import Document
from pptx import Presentation
import PyPDF2
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse, validate_file_path
load_dotenv()
async def extract_pdf_text(
file_path: str,
page_range: str | None = None
) -> Union[str, TextContent]:
"""
Extract text from PDF file.
Args:
file_path: Path to PDF file
page_range: Optional page range (e.g., "1-5" or "1,3,5")
Returns:
TextContent with extracted text
"""
try:
path = validate_file_path(file_path)
logging.info(f"📄 Extracting PDF: {path}")
with open(path, 'rb') as file:
reader = PyPDF2.PdfReader(file)
total_pages = len(reader.pages)
# Parse page range
if page_range:
pages_to_extract = parse_page_range(page_range, total_pages)
else:
pages_to_extract = range(total_pages)
# Extract text
text_parts = []
for page_num in pages_to_extract:
if page_num < total_pages:
page = reader.pages[page_num]
text = page.extract_text()
text_parts.append(f"--- Page {page_num + 1} ---\n{text}\n")
full_text = "\n".join(text_parts)
result = {
"file_name": path.name,
"file_type": "pdf",
"total_pages": total_pages,
"pages_extracted": len(pages_to_extract),
"text": full_text[:50000], # Limit to 50k chars
"text_length": len(full_text),
"truncated": len(full_text) > 50000
}
logging.info(f"✅ Extracted {len(pages_to_extract)} pages from PDF")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path), "pages": total_pages}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"PDF extraction failed: {str(e)}"
logging.error(f"PDF error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "pdf_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_docx_content(
file_path: str
) -> Union[str, TextContent]:
"""
Extract content from DOCX file.
Args:
file_path: Path to DOCX file
Returns:
TextContent with extracted content
"""
try:
path = validate_file_path(file_path)
logging.info(f"📄 Extracting DOCX: {path}")
doc = Document(path)
# Extract paragraphs
paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
# Extract tables
tables_data = []
for table in doc.tables:
table_data = []
for row in table.rows:
row_data = [cell.text for cell in row.cells]
table_data.append(row_data)
tables_data.append(table_data)
full_text = "\n\n".join(paragraphs)
result = {
"file_name": path.name,
"file_type": "docx",
"paragraphs": len(paragraphs),
"tables": len(tables_data),
"text": full_text[:50000],
"text_length": len(full_text),
"truncated": len(full_text) > 50000,
"tables_data": tables_data if tables_data else []
}
logging.info(f"✅ Extracted DOCX: {len(paragraphs)} paragraphs, {len(tables_data)} tables")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"DOCX extraction failed: {str(e)}"
logging.error(f"DOCX error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "docx_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_pptx_content(
file_path: str
) -> Union[str, TextContent]:
"""
Extract content from PPTX file.
Args:
file_path: Path to PPTX file
Returns:
TextContent with extracted content
"""
try:
path = validate_file_path(file_path)
logging.info(f"📊 Extracting PPTX: {path}")
prs = Presentation(path)
slides_content = []
for slide_num, slide in enumerate(prs.slides, 1):
slide_text = []
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text.strip():
slide_text.append(shape.text)
if slide_text:
slides_content.append({
"slide_number": slide_num,
"text": "\n".join(slide_text)
})
full_text = "\n\n".join([f"=== Slide {s['slide_number']} ===\n{s['text']}" for s in slides_content])
result = {
"file_name": path.name,
"file_type": "pptx",
"total_slides": len(prs.slides),
"slides_with_content": len(slides_content),
"text": full_text[:50000],
"text_length": len(full_text),
"truncated": len(full_text) > 50000,
"slides": slides_content
}
logging.info(f"✅ Extracted PPTX: {len(prs.slides)} slides")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"PPTX extraction failed: {str(e)}"
logging.error(f"PPTX error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "pptx_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_csv_content(
file_path: str,
max_rows: int = 1000
) -> Union[str, TextContent]:
"""
Extract and parse CSV file content.
Args:
file_path: Path to CSV file
max_rows: Maximum rows to read
Returns:
TextContent with parsed CSV data
"""
try:
path = validate_file_path(file_path)
logging.info(f"📊 Parsing CSV: {path}")
# Read CSV with pandas
df = pd.read_csv(path, nrows=max_rows)
result = {
"file_name": path.name,
"file_type": "csv",
"rows": len(df),
"columns": len(df.columns),
"column_names": df.columns.tolist(),
"data": df.to_dict(orient="records"),
"preview": df.head(10).to_string(),
"truncated": len(df) == max_rows
}
logging.info(f"✅ Parsed CSV: {len(df)} rows, {len(df.columns)} columns")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"CSV parsing failed: {str(e)}"
logging.error(f"CSV error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "csv_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
def parse_page_range(page_range: str, total_pages: int) -> list[int]:
"""
Parse page range string into list of page numbers.
Args:
page_range: String like "1-5" or "1,3,5" or "1-3,7,9-11"
total_pages: Total number of pages
Returns:
List of page numbers (0-indexed)
"""
pages = []
for part in page_range.split(","):
part = part.strip()
if not part:
# Trailing/duplicate commas (e.g. "1,3," or "1,,3") are common in
# LLM tool args; skip empty segments instead of int("").
continue
if "-" in part:
bounds = part.split("-")
if len(bounds) != 2 or not bounds[0] or not bounds[1]:
raise ValueError(f"Invalid page range segment: {part!r}")
start, end = int(bounds[0]), int(bounds[1])
# Clamp both ends: the caller's guard is `page_num < total_pages`,
# which a negative index passes, and reader.pages[-1] is the LAST
# page -- so an unclamped start silently returns the wrong page.
pages.extend(range(max(0, start - 1), min(end, total_pages)))
else:
page_num = int(part) - 1
if 0 <= page_num < total_pages:
pages.append(page_num)
return sorted(set(pages))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,581 @@
"""File-system perception tools and tightly scoped mutation helpers.
Read operations retain their historical behavior. Move, copy, and delete are
available only beneath the directory named by ``PERCEPTION_MUTATION_ROOT``.
They reject absolute paths, traversal, symlinks, and the private quarantine
directory. Delete and overwrite are implemented as reversible quarantine
moves so the experiment never has to destroy user data.
"""
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import traceback
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Union
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse, validate_file_path
load_dotenv()
MUTATION_ROOT_ENV = "PERCEPTION_MUTATION_ROOT"
QUARANTINE_DIRECTORY = ".perception-trash"
def _mutation_error(operation: str, exc: Exception) -> TextContent:
action_response = ActionResponse(
success=False,
message=f"Filesystem {operation} failed: {exc}",
metadata={
"operation": operation,
"error_type": type(exc).__name__,
"mutation_root_env": MUTATION_ROOT_ENV,
},
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump()),
)
def _mutation_root() -> Path:
configured = os.getenv(MUTATION_ROOT_ENV, "").strip()
if not configured:
raise PermissionError(
f"{MUTATION_ROOT_ENV} must name an explicit experiment workspace"
)
root = Path(configured).expanduser().resolve(strict=True)
if not root.is_dir():
raise NotADirectoryError(f"Mutation root is not a directory: {root}")
return root
def _relative_parts(value: str) -> tuple[str, ...]:
if not isinstance(value, str) or not value.strip():
raise ValueError("Path must be a non-empty relative path")
path = Path(value)
if path.is_absolute():
raise PermissionError("Absolute paths are not allowed for filesystem mutations")
if ".." in path.parts:
raise PermissionError("Parent traversal is not allowed for filesystem mutations")
parts = tuple(part for part in path.parts if part not in {"", "."})
if not parts:
raise PermissionError("The mutation workspace root itself cannot be changed")
if parts[0] == QUARANTINE_DIRECTORY:
raise PermissionError("The filesystem quarantine is managed by the server")
return parts
def _inside_root(root: Path, path: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_mutation_path(
root: Path,
value: str,
*,
must_exist: bool,
) -> Path:
parts = _relative_parts(value)
unresolved = root.joinpath(*parts)
if must_exist:
resolved = unresolved.resolve(strict=True)
else:
parent = unresolved.parent.resolve(strict=True)
if not parent.is_dir():
raise NotADirectoryError(f"Destination parent is not a directory: {parent}")
resolved = parent / unresolved.name
if not _inside_root(root, resolved):
raise PermissionError("Resolved path escapes the configured mutation root")
if unresolved.is_symlink() or (resolved.exists() and resolved.is_symlink()):
raise PermissionError("Symbolic links are not allowed for filesystem mutations")
return resolved
def _assert_no_symlinks(path: Path) -> None:
if path.is_symlink():
raise PermissionError(f"Symbolic links are not allowed: {path}")
if path.is_dir():
for item in path.rglob("*"):
if item.is_symlink():
raise PermissionError(f"Symbolic links are not allowed: {item}")
def _fingerprint(path: Path) -> dict:
"""Return a deterministic content receipt for one file or directory."""
digest = hashlib.sha256()
total_bytes = 0
entries = 0
if path.is_file():
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
total_bytes += len(block)
entries = 1
kind = "file"
elif path.is_dir():
kind = "directory"
for item in sorted(path.rglob("*"), key=lambda candidate: candidate.as_posix()):
if item.is_symlink():
raise PermissionError(f"Symbolic links are not allowed: {item}")
relative = item.relative_to(path).as_posix()
item_kind = "directory" if item.is_dir() else "file"
digest.update(f"{item_kind}\0{relative}\0".encode("utf-8"))
entries += 1
if item.is_file():
with item.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
total_bytes += len(block)
else:
raise ValueError(f"Unsupported filesystem object: {path}")
return {
"kind": kind,
"sha256": digest.hexdigest(),
"bytes": total_bytes,
"entries": entries,
}
def _quarantine(root: Path, path: Path) -> Path:
trash = root / QUARANTINE_DIRECTORY
trash.mkdir(mode=0o700, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
destination = trash / f"{stamp}-{uuid.uuid4().hex}-{path.name}"
path.rename(destination)
return destination
async def move_path(
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> TextContent:
"""Move a file/directory inside the explicit mutation workspace."""
operation = "move"
quarantined_destination = None
try:
root = _mutation_root()
source = _resolve_mutation_path(root, source_path, must_exist=True)
destination = _resolve_mutation_path(root, destination_path, must_exist=False)
if source == destination:
raise ValueError("Source and destination must be different")
_assert_no_symlinks(source)
before = _fingerprint(source)
if destination.exists():
if not overwrite:
raise FileExistsError(f"Destination already exists: {destination_path}")
_assert_no_symlinks(destination)
quarantined_destination = _quarantine(root, destination)
try:
source.rename(destination)
except Exception:
if quarantined_destination and not destination.exists():
quarantined_destination.rename(destination)
raise
after = _fingerprint(destination)
if before != after or source.exists():
raise RuntimeError("Post-move verification failed")
response = ActionResponse(
success=True,
message={
"operation": operation,
"source": source_path,
"destination": destination_path,
"source_exists_after": source.exists(),
"destination_fingerprint": after,
"replaced_path_quarantine": (
str(quarantined_destination.relative_to(root))
if quarantined_destination else None
),
},
metadata={
"mutation_root": str(root),
"pre_operation_fingerprint": before,
"verification": "source absent and destination fingerprint matches",
},
)
return TextContent(type="text", text=json.dumps(response.model_dump()))
except Exception as exc:
logging.error("Filesystem move error: %s", traceback.format_exc())
return _mutation_error(operation, exc)
async def copy_path(
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> TextContent:
"""Copy a file/directory inside the explicit mutation workspace."""
operation = "copy"
quarantined_destination = None
try:
root = _mutation_root()
source = _resolve_mutation_path(root, source_path, must_exist=True)
destination = _resolve_mutation_path(root, destination_path, must_exist=False)
if source == destination:
raise ValueError("Source and destination must be different")
_assert_no_symlinks(source)
before = _fingerprint(source)
if destination.exists():
if not overwrite:
raise FileExistsError(f"Destination already exists: {destination_path}")
_assert_no_symlinks(destination)
quarantined_destination = _quarantine(root, destination)
try:
if source.is_dir():
shutil.copytree(source, destination, symlinks=False)
else:
shutil.copy2(source, destination)
except Exception:
if destination.exists():
if destination.is_dir():
shutil.rmtree(destination)
else:
destination.unlink()
if quarantined_destination:
quarantined_destination.rename(destination)
raise
after = _fingerprint(destination)
if before != after or not source.exists():
raise RuntimeError("Post-copy verification failed")
response = ActionResponse(
success=True,
message={
"operation": operation,
"source": source_path,
"destination": destination_path,
"source_exists_after": source.exists(),
"destination_fingerprint": after,
"replaced_path_quarantine": (
str(quarantined_destination.relative_to(root))
if quarantined_destination else None
),
},
metadata={
"mutation_root": str(root),
"pre_operation_fingerprint": before,
"verification": "source retained and destination fingerprint matches",
},
)
return TextContent(type="text", text=json.dumps(response.model_dump()))
except Exception as exc:
logging.error("Filesystem copy error: %s", traceback.format_exc())
return _mutation_error(operation, exc)
async def delete_path(path: str) -> TextContent:
"""Remove a path from the workspace by moving it to private quarantine."""
operation = "delete"
try:
root = _mutation_root()
target = _resolve_mutation_path(root, path, must_exist=True)
_assert_no_symlinks(target)
before = _fingerprint(target)
quarantine = _quarantine(root, target)
after = _fingerprint(quarantine)
if target.exists() or before != after:
raise RuntimeError("Post-delete verification failed")
response = ActionResponse(
success=True,
message={
"operation": operation,
"path": path,
"path_exists_after": target.exists(),
"quarantine_path": str(quarantine.relative_to(root)),
"reversible": True,
"quarantine_fingerprint": after,
},
metadata={
"mutation_root": str(root),
"pre_operation_fingerprint": before,
"verification": "original path absent and quarantine fingerprint matches",
},
)
return TextContent(type="text", text=json.dumps(response.model_dump()))
except Exception as exc:
logging.error("Filesystem delete error: %s", traceback.format_exc())
return _mutation_error(operation, exc)
async def read_file(
file_path: str,
encoding: str = "utf-8",
max_length: int = 50000
) -> Union[str, TextContent]:
"""
Read a file and return its contents.
Args:
file_path: Path to the file
encoding: File encoding (default: utf-8)
max_length: Maximum number of characters to return
Returns:
TextContent with file contents
"""
try:
path = validate_file_path(file_path)
logging.info(f"📖 Reading file: {path}")
with open(path, 'r', encoding=encoding, errors='ignore') as f:
content = f.read()
if max_length < 0:
max_length = len(content)
truncated = len(content) > max_length
if truncated:
content = content[:max_length]
result = {
"file_path": str(path),
"content": content,
"size_bytes": path.stat().st_size,
"truncated": truncated,
"encoding": encoding
}
logging.info(f"✅ Successfully read file ({len(content)} characters)")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"File reading failed: {str(e)}"
logging.error(f"File read error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "file_read_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def grep_search(
pattern: str,
directory: str,
file_pattern: str = "*",
recursive: bool = True,
case_sensitive: bool = False,
max_results: int = 100
) -> Union[str, TextContent]:
"""
Search for a pattern in files using grep-like functionality.
Args:
pattern: Regular expression pattern to search for
directory: Directory to search in
file_pattern: File pattern to match (e.g., "*.py")
recursive: Whether to search recursively
case_sensitive: Whether search is case-sensitive
max_results: Maximum number of results to return
Returns:
TextContent with search results
"""
try:
dir_path = Path(directory).expanduser().resolve()
if not dir_path.exists():
raise FileNotFoundError(f"Directory not found: {dir_path}")
if not dir_path.is_dir():
raise ValueError(f"Path is not a directory: {dir_path}")
logging.info(f"🔍 Searching for pattern '{pattern}' in {dir_path}")
results = []
if max_results <= 0:
action_response = ActionResponse(
success=True,
message={
"pattern": pattern,
"results": results,
"total_found": 0,
"truncated": False,
},
metadata={
"directory": str(dir_path),
"file_pattern": file_pattern,
"recursive": recursive,
},
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump()),
)
flags = re.IGNORECASE if not case_sensitive else 0
regex = re.compile(pattern, flags)
if recursive:
files = dir_path.rglob(file_pattern)
else:
files = dir_path.glob(file_pattern)
for file_path in files:
if not file_path.is_file():
continue
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
results.append({
"file": str(file_path.relative_to(dir_path)),
"line_number": line_num,
"line": line.strip(),
"absolute_path": str(file_path)
})
if len(results) >= max_results:
break
if len(results) >= max_results:
break
except Exception as e:
logging.warning(f"Error reading {file_path}: {e}")
continue
logging.info(f"✅ Found {len(results)} matches")
action_response = ActionResponse(
success=True,
message={
"pattern": pattern,
"results": results,
"total_found": len(results),
"truncated": len(results) >= max_results
},
metadata={
"directory": str(dir_path),
"file_pattern": file_pattern,
"recursive": recursive
}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Grep search failed: {str(e)}"
logging.error(f"Grep error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "grep_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def summarize_text(
text: str,
max_length: int = 500,
use_llm: bool = True
) -> Union[str, TextContent]:
"""
Summarize long text content.
Args:
text: Text to summarize
max_length: Target summary length
use_llm: Whether to use LLM for summarization (if available)
Returns:
TextContent with summary
"""
try:
logging.info(f"📝 Summarizing text ({len(text)} characters)")
if use_llm:
# TODO: Integrate with LLM API for better summarization
# For now, use simple extraction
summary = "LLM summarization not yet implemented. Using simple extraction."
method = "placeholder"
else:
# Simple extractive summarization: first N sentences
sentences = re.split(r'[.!?]+', text)
summary = ""
for sentence in sentences:
if len(summary) + len(sentence) > max_length:
break
summary += sentence.strip() + ". "
method = "extractive"
if not summary or summary == "LLM summarization not yet implemented. Using simple extraction.":
# Fallback: just truncate
summary = text[:max_length] + "..." if len(text) > max_length else text
method = "truncation"
result = {
"original_length": len(text),
"summary_length": len(summary),
"summary": summary,
"method": method,
"compression_ratio": len(summary) / len(text) if len(text) > 0 else 0
}
logging.info(f"✅ Generated summary ({len(summary)} characters)")
action_response = ActionResponse(
success=True,
message=result,
metadata={"method": method}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Text summarization failed: {str(e)}"
logging.error(f"Summarization error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "summarization_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,209 @@
"""
Enhanced Google Search tools.
Based on AWorld google-search server.
"""
import json
import logging
import os
import traceback
from typing import Union
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse
load_dotenv()
async def google_search_api(
query: str,
num_results: int = 5,
safe_search: bool = True,
language: str = "en",
country: str = "us"
) -> Union[str, TextContent]:
"""
Search Google using Custom Search API.
Args:
query: Search query
num_results: Number of results (1-10)
safe_search: Enable safe search
language: Language code
country: Country code
Returns:
TextContent with search results
"""
try:
api_key = os.getenv("GOOGLE_API_KEY")
cse_id = os.getenv("GOOGLE_CSE_ID")
if not api_key or not cse_id:
return await _fallback_google_search(query, num_results)
url = "https://www.googleapis.com/customsearch/v1"
params = {
"key": api_key,
"cx": cse_id,
"q": query,
"num": min(num_results, 10),
"safe": "active" if safe_search else "off",
"hl": language,
"gl": country
}
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
results = []
if "items" in data:
for item in data["items"]:
results.append({
"title": item.get("title"),
"url": item.get("link"),
"snippet": item.get("snippet"),
"display_url": item.get("displayLink")
})
action_response = ActionResponse(
success=True,
message={"query": query, "results": results, "count": len(results)},
metadata={"engine": "google_api", "results_count": len(results)}
)
logging.info(f"✅ Google Search: {len(results)} results")
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
logging.error(f"Google API search failed: {traceback.format_exc()}")
return await _fallback_google_search(query, num_results)
async def _fallback_google_search(query: str, num_results: int) -> TextContent:
"""Fallback to DuckDuckGo if Google API not available."""
try:
logging.info("Using DuckDuckGo fallback")
url = "https://html.duckduckgo.com/html/"
headers = {"User-Agent": "Mozilla/5.0"}
data = {"q": query}
response = requests.post(url, data=data, headers=headers, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
result_divs = soup.find_all('div', class_='result')
results = []
for i, div in enumerate(result_divs[:num_results]):
title_tag = div.find('a', class_='result__a')
if title_tag:
results.append({
"title": title_tag.get_text(strip=True),
"url": title_tag.get('href', ''),
"snippet": div.find('a', class_='result__snippet').get_text(strip=True) if div.find('a', class_='result__snippet') else ""
})
action_response = ActionResponse(
success=True,
message={"query": query, "results": results, "count": len(results)},
metadata={"engine": "duckduckgo_fallback"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Search failed: {str(e)}",
metadata={"error_type": "search_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def read_webpage_content(
url: str,
extract_links: bool = False
) -> Union[str, TextContent]:
"""
Read and extract content from webpage.
Args:
url: URL to read
extract_links: Whether to extract links
Returns:
TextContent with webpage content
"""
try:
headers = {"User-Agent": "Mozilla/5.0"}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# Remove scripts and styles
for script in soup(["script", "style"]):
script.decompose()
text = soup.get_text()
lines = (line.strip() for line in text.splitlines())
text = ' '.join(line for line in lines if line)
result = {
"url": url,
"title": soup.title.string if soup.title else "No title",
"text": text[:10000],
"text_length": len(text)
}
if extract_links:
links = []
for link in soup.find_all('a', href=True)[:50]:
links.append({
"text": link.get_text().strip(),
"href": link['href']
})
result["links"] = links
action_response = ActionResponse(
success=True,
message=result,
metadata={"url": url}
)
logging.info(f"✅ Read webpage: {len(text)} chars")
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Failed to read webpage: {str(e)}",
metadata={"error_type": "webpage_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
+704
View File
@@ -0,0 +1,704 @@
"""
Main MCP server for perception tools.
This MCP server provides comprehensive perception capabilities including:
- Search tools (web search, knowledge base, file download)
- Multimodal understanding (web pages, documents, images, videos)
- File system operations (read, grep, summarization)
- Public data sources (weather, stocks, currency, Wikipedia, ArXiv, Wayback)
- Private data sources (Google Calendar, Notion)
"""
import logging
from dotenv import load_dotenv
from mcp.server import MCPServer
from pydantic import Field
# Import all tool functions
from search_tools import search_web, download_file, search_knowledge_base
from multimodal_tools import read_webpage, read_document, parse_image, parse_video, extract_youtube_transcript, download_youtube_video
from filesystem_tools import (
copy_path,
delete_path,
grep_search,
move_path,
read_file,
summarize_text,
)
from public_data_tools import (
get_weather, get_stock_price, convert_currency,
search_wikipedia, search_arxiv, search_wayback,
get_crypto_price, search_location, search_poi
)
from private_data_tools import get_calendar_events, search_notion
from pubchem_tools import search_compounds, get_compound_properties, get_compound_synonyms, search_similar_compounds
from yahoo_finance_tools import get_stock_quote, get_historical_data, get_company_info, get_financial_statements
from document_processing_tools import extract_pdf_text, extract_docx_content, extract_pptx_content, extract_csv_content
from media_processing_tools import transcribe_audio_whisper, extract_audio_metadata, extract_text_ocr, analyze_image_ai, extract_video_keyframes, analyze_video_ai, trim_audio, get_image_metadata
from google_search_enhanced import google_search_api, read_webpage_content
from wiki_enhanced import get_article_content, get_article_categories, get_article_links, get_article_history
from arxiv_enhanced import get_paper_details, download_paper, get_arxiv_categories
from wayback_enhanced import get_archived_content
from expanded_catalog import enrich_existing_tools, register_expanded_tools
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
load_dotenv()
# Initialize MCP server
mcp = MCPServer(
"perception-tools",
instructions="""
Perception Tools MCP Server
A comprehensive MCP server providing various perception and data retrieval capabilities:
## Search Tools
- Web search using DuckDuckGo (free, no API key required)
- Local knowledge base search
- File download from URLs
## Multimodal Understanding
- Web page content extraction
- Document reading (PDF, DOCX, PPTX)
- Image parsing and analysis
- Video metadata extraction
## File System Tools
- File reading with encoding support
- Grep-like pattern search
- Text summarization
## Public Data Sources
- Weather information
- Stock prices and market data
- Cryptocurrency prices (CoinGecko)
- Currency conversion
- Location search / geocoding (Nominatim)
- Points of Interest search (Overpass)
- Wikipedia search
- ArXiv academic papers
- Wayback Machine archives
## Private Data Sources
- Google Calendar events
- Notion workspace search
"""
)
# ============================================================================
# SEARCH TOOLS
# ============================================================================
@mcp.tool(description="Search the web using DuckDuckGo (free, no API key required)")
async def web_search(
query: str = Field(description="Search query string"),
num_results: int = Field(default=5, description="Number of results (1-10)"),
region: str = Field(default="wt-wt", description="Region code (e.g., 'us-en', 'uk-en', 'wt-wt' for worldwide)")
):
"""Search the web and return results."""
return await search_web(query, num_results, region)
@mcp.tool(description="Download a file from a URL to local storage")
async def download(
url: str = Field(description="URL to download from"),
output_path: str = Field(description="Local path to save the file"),
overwrite: bool = Field(default=False, description="Overwrite existing file"),
timeout: int = Field(default=180, description="Download timeout in seconds")
):
"""Download a file from URL."""
return await download_file(url, output_path, overwrite, timeout)
@mcp.tool(description="Search a local knowledge base directory")
async def knowledge_base_search(
query: str = Field(description="Search query"),
knowledge_base_path: str = Field(description="Path to knowledge base directory"),
top_k: int = Field(default=5, description="Number of top results")
):
"""Search local knowledge base."""
return await search_knowledge_base(query, knowledge_base_path, top_k)
# ============================================================================
# MULTIMODAL UNDERSTANDING TOOLS
# ============================================================================
@mcp.tool(description="Read and extract content from a webpage")
async def webpage_reader(
url: str = Field(description="URL of the webpage"),
extract_text: bool = Field(default=True, description="Extract text content"),
extract_links: bool = Field(default=False, description="Extract links")
):
"""Read webpage content."""
return await read_webpage(url, extract_text, extract_links)
@mcp.tool(description="Read and extract content from documents (PDF, DOCX, PPTX)")
async def document_reader(
file_path: str = Field(description="Path to document file or URL"),
extract_images: bool = Field(default=False, description="Extract images")
):
"""Read document content."""
return await read_document(file_path, extract_images)
@mcp.tool(description="Parse and analyze image files")
async def image_parser(
image_path: str = Field(description="Path to image file or URL"),
use_llm: bool = Field(default=True, description="Use LLM for analysis")
):
"""Parse image content."""
return await parse_image(image_path, use_llm)
@mcp.tool(description="Parse and extract metadata from video files")
async def video_parser(
video_path: str = Field(description="Path to video file or URL"),
extract_frames: bool = Field(default=False, description="Extract sample frames"),
frame_interval: int = Field(default=30, description="Frame extraction interval")
):
"""Parse video metadata."""
return await parse_video(video_path, extract_frames, frame_interval)
# ============================================================================
# FILE SYSTEM TOOLS
# ============================================================================
@mcp.tool(description="Read a file and return its contents")
async def file_reader(
file_path: str = Field(description="Path to the file"),
encoding: str = Field(default="utf-8", description="File encoding"),
max_length: int = Field(default=50000, description="Maximum characters to read")
):
"""Read file contents."""
return await read_file(file_path, encoding, max_length)
@mcp.tool(description="Search for patterns in files (grep-like functionality)")
async def grep(
pattern: str = Field(description="Regular expression pattern"),
directory: str = Field(description="Directory to search in"),
file_pattern: str = Field(default="*", description="File pattern (e.g., *.py)"),
recursive: bool = Field(default=True, description="Search recursively"),
case_sensitive: bool = Field(default=False, description="Case-sensitive search"),
max_results: int = Field(default=100, description="Maximum results")
):
"""Search files for pattern."""
return await grep_search(pattern, directory, file_pattern, recursive, case_sensitive, max_results)
@mcp.tool(description="Summarize long text content")
async def text_summarizer(
text: str = Field(description="Text to summarize"),
max_length: int = Field(default=500, description="Target summary length"),
use_llm: bool = Field(default=True, description="Use LLM for summarization")
):
"""Summarize text."""
return await summarize_text(text, max_length, use_llm)
@mcp.tool(description="Move a file or directory inside the configured mutation workspace")
async def filesystem_move(
source_path: str = Field(description="Relative source path beneath PERCEPTION_MUTATION_ROOT"),
destination_path: str = Field(description="Relative destination path beneath PERCEPTION_MUTATION_ROOT"),
overwrite: bool = Field(default=False, description="Quarantine an existing destination before moving"),
):
"""Move one workspace-confined filesystem object."""
return await move_path(source_path, destination_path, overwrite)
@mcp.tool(description="Copy a file or directory inside the configured mutation workspace")
async def filesystem_copy(
source_path: str = Field(description="Relative source path beneath PERCEPTION_MUTATION_ROOT"),
destination_path: str = Field(description="Relative destination path beneath PERCEPTION_MUTATION_ROOT"),
overwrite: bool = Field(default=False, description="Quarantine an existing destination before copying"),
):
"""Copy one workspace-confined filesystem object."""
return await copy_path(source_path, destination_path, overwrite)
@mcp.tool(description="Delete a file or directory from the configured mutation workspace using reversible quarantine")
async def filesystem_delete(
path: str = Field(description="Relative path beneath PERCEPTION_MUTATION_ROOT"),
):
"""Quarantine one workspace-confined filesystem object."""
return await delete_path(path)
# ============================================================================
# PUBLIC DATA SOURCE TOOLS
# ============================================================================
@mcp.tool(description="Get current weather information for a location (Open-Meteo, free, no API key)")
async def weather(
location: str = Field(description="City name (automatically geocoded)"),
latitude: float | None = Field(default=None, description="Latitude coordinate (optional)"),
longitude: float | None = Field(default=None, description="Longitude coordinate (optional)")
):
"""Get weather data."""
return await get_weather(location, latitude, longitude)
@mcp.tool(description="Get stock price and market information")
async def stock_price(
symbol: str = Field(description="Stock ticker symbol (e.g., AAPL)"),
interval: str = Field(default="1d", description="Data interval")
):
"""Get stock price."""
return await get_stock_price(symbol, interval)
@mcp.tool(description="Convert between currencies")
async def currency_converter(
amount: float = Field(description="Amount to convert"),
from_currency: str = Field(description="Source currency code (e.g., USD)"),
to_currency: str = Field(description="Target currency code (e.g., EUR)")
):
"""Convert currency."""
return await convert_currency(amount, from_currency, to_currency)
@mcp.tool(description="Get cryptocurrency price information (CoinGecko, free, no API key)")
async def crypto_price(
symbol: str = Field(description="Cryptocurrency symbol or ID (e.g., bitcoin, ethereum, btc, eth)"),
vs_currency: str = Field(default="usd", description="Target currency (usd, eur, gbp, etc.)")
):
"""Get cryptocurrency price."""
return await get_crypto_price(symbol, vs_currency)
@mcp.tool(description="Search for locations using Nominatim/OpenStreetMap (free, no API key)")
async def location_search(
query: str = Field(description="Location query (e.g., 'Eiffel Tower', 'New York', 'Tokyo')"),
limit: int = Field(default=5, description="Maximum number of results (1-50)"),
country_code: str | None = Field(default=None, description="Country code filter (e.g., 'us', 'gb', 'fr')")
):
"""Search locations (geocoding)."""
return await search_location(query, limit, country_code)
@mcp.tool(description="Search for Points of Interest near a location using Overpass/OpenStreetMap (free, no API key)")
async def poi_search(
query: str = Field(description="Type of POI (e.g., 'restaurant', 'cafe', 'hospital', 'atm', 'hotel')"),
latitude: float = Field(description="Center latitude coordinate"),
longitude: float = Field(description="Center longitude coordinate"),
radius: int = Field(default=1000, description="Search radius in meters"),
limit: int = Field(default=10, description="Maximum number of results")
):
"""Search points of interest."""
return await search_poi(query, latitude, longitude, radius, limit)
@mcp.tool(description="Search Wikipedia and get article summary")
async def wikipedia_search(
query: str = Field(description="Search query"),
language: str = Field(default="en", description="Wikipedia language"),
sentences: int = Field(default=5, description="Summary sentence count")
):
"""Search Wikipedia."""
return await search_wikipedia(query, language, sentences)
@mcp.tool(description="Search ArXiv for academic papers")
async def arxiv_search(
query: str = Field(description="Search query"),
max_results: int = Field(default=5, description="Maximum results"),
sort_by: str = Field(default="relevance", description="Sort method")
):
"""Search ArXiv."""
return await search_arxiv(query, max_results, sort_by)
@mcp.tool(description="Search Wayback Machine for archived web pages")
async def wayback_search(
url: str = Field(description="URL to search for"),
year: int | None = Field(default=None, description="Filter by year"),
limit: int = Field(default=10, description="Maximum snapshots")
):
"""Search Wayback Machine."""
return await search_wayback(url, year, limit)
# ============================================================================
# YOUTUBE TOOLS
# ============================================================================
@mcp.tool(description="Extract transcript from a YouTube video")
async def youtube_transcript(
video_id: str = Field(description="YouTube video ID or URL"),
language_code: str = Field(default="en", description="Language code for transcript"),
translate_to_language: str | None = Field(default=None, description="Translate to this language")
):
"""Extract YouTube transcript."""
return await extract_youtube_transcript(video_id, language_code, translate_to_language)
# ============================================================================
# PUBCHEM CHEMICAL DATA TOOLS
# ============================================================================
@mcp.tool(description="Search PubChem for chemical compounds")
async def pubchem_search(
query: str = Field(description="Search term or identifier"),
search_type: str = Field(default="name", description="Type: name, cid, smiles, inchi, formula"),
max_results: int = Field(default=10, description="Maximum results (1-100)")
):
"""Search PubChem compounds."""
return await search_compounds(query, search_type, max_results)
@mcp.tool(description="Get detailed properties for a PubChem compound")
async def pubchem_properties(
cid: int = Field(description="PubChem Compound ID"),
properties: list[str] | None = Field(default=None, description="List of property names")
):
"""Get compound properties."""
return await get_compound_properties(cid, properties)
@mcp.tool(description="Get synonyms for a PubChem compound")
async def pubchem_synonyms(
cid: int = Field(description="PubChem Compound ID"),
max_synonyms: int = Field(default=20, description="Maximum synonyms (1-100)")
):
"""Get compound synonyms."""
return await get_compound_synonyms(cid, max_synonyms)
@mcp.tool(description="Search for structurally similar compounds in PubChem")
async def pubchem_similar(
cid: int = Field(description="Reference compound CID"),
similarity_threshold: float = Field(default=0.9, description="Similarity threshold (0.0-1.0)"),
max_results: int = Field(default=10, description="Maximum results (1-50)")
):
"""Search similar compounds."""
return await search_similar_compounds(cid, similarity_threshold, max_results)
# ============================================================================
# YAHOO FINANCE TOOLS
# ============================================================================
@mcp.tool(description="Get current stock quote and market data")
async def yfinance_quote(
symbol: str = Field(description="Stock ticker symbol (e.g., AAPL, MSFT)")
):
"""Get stock quote."""
return await get_stock_quote(symbol)
@mcp.tool(description="Get historical stock price data")
async def yfinance_historical(
symbol: str = Field(description="Stock ticker symbol"),
start: str = Field(description="Start date (YYYY-MM-DD)"),
end: str = Field(description="End date (YYYY-MM-DD)"),
interval: str = Field(default="1d", description="Data interval (1d, 1wk, 1mo)"),
max_rows_preview: int = Field(default=10, description="Max rows in preview")
):
"""Get historical stock data."""
return await get_historical_data(symbol, start, end, interval, max_rows_preview)
@mcp.tool(description="Get comprehensive company information")
async def yfinance_company_info(
symbol: str = Field(description="Stock ticker symbol")
):
"""Get company information."""
return await get_company_info(symbol)
@mcp.tool(description="Get financial statements (income statement, balance sheet, cash flow)")
async def yfinance_financials(
symbol: str = Field(description="Stock ticker symbol"),
statement_type: str = Field(description="Type: income_statement, balance_sheet, cash_flow"),
period_type: str = Field(default="annual", description="Period: annual or quarterly"),
max_columns_preview: int = Field(default=4, description="Max periods to show")
):
"""Get financial statements."""
return await get_financial_statements(symbol, statement_type, period_type, max_columns_preview)
# ============================================================================
# DOCUMENT PROCESSING TOOLS
# ============================================================================
@mcp.tool(description="Extract text from PDF file with optional page range")
async def pdf_extract(
file_path: str = Field(description="Path to PDF file"),
page_range: str | None = Field(default=None, description="Page range (e.g., '1-5' or '1,3,5')")
):
"""Extract text from PDF."""
return await extract_pdf_text(file_path, page_range)
@mcp.tool(description="Extract content from Word document (DOCX)")
async def docx_extract(
file_path: str = Field(description="Path to DOCX file")
):
"""Extract content from DOCX."""
return await extract_docx_content(file_path)
@mcp.tool(description="Extract content from PowerPoint presentation (PPTX)")
async def pptx_extract(
file_path: str = Field(description="Path to PPTX file")
):
"""Extract content from PPTX."""
return await extract_pptx_content(file_path)
@mcp.tool(description="Extract and parse CSV file data")
async def csv_parse(
file_path: str = Field(description="Path to CSV file"),
max_rows: int = Field(default=1000, description="Maximum rows to read")
):
"""Parse CSV data."""
return await extract_csv_content(file_path, max_rows)
# ============================================================================
# MEDIA PROCESSING TOOLS
# ============================================================================
@mcp.tool(description="Transcribe audio to text using Whisper")
async def audio_transcribe(
file_path: str = Field(description="Path to audio file"),
model_size: str = Field(default="base", description="Whisper model size"),
language: str = Field(default="en", description="Language code")
):
"""Transcribe audio to text."""
return await transcribe_audio_whisper(file_path, model_size, language)
@mcp.tool(description="Extract audio file metadata")
async def audio_metadata(
file_path: str = Field(description="Path to audio file")
):
"""Extract audio metadata."""
return await extract_audio_metadata(file_path)
@mcp.tool(description="Extract text from image using OCR")
async def image_ocr(
image_path: str = Field(description="Path to image file"),
language: str = Field(default="eng", description="OCR language")
):
"""Extract text from image using OCR."""
return await extract_text_ocr(image_path, language)
@mcp.tool(description="Analyze image using AI vision")
async def image_analyze(
image_path: str = Field(description="Path to image file"),
prompt: str = Field(default="Describe this image in detail", description="Analysis prompt")
):
"""Analyze image with AI."""
return await analyze_image_ai(image_path, prompt)
@mcp.tool(description="Extract keyframes from video")
async def video_keyframes(
video_path: str = Field(description="Path to video file"),
num_frames: int = Field(default=10, description="Number of keyframes to extract")
):
"""Extract video keyframes."""
return await extract_video_keyframes(video_path, num_frames)
@mcp.tool(description="Analyze video content using AI vision")
async def video_analyze(
video_path: str = Field(description="Path to video file"),
num_frames: int = Field(default=5, description="Number of frames to analyze"),
prompt: str = Field(default="Analyze this video and describe what's happening", description="Analysis prompt")
):
"""Analyze video with AI."""
return await analyze_video_ai(video_path, num_frames, prompt)
@mcp.tool(description="Trim audio file to specific time range")
async def audio_trim(
audio_path: str = Field(description="Path to audio file"),
start_time: float = Field(description="Start time in seconds"),
duration: float | None = Field(default=None, description="Duration in seconds"),
output_path: str | None = Field(default=None, description="Output file path")
):
"""Trim audio file."""
return await trim_audio(audio_path, start_time, duration, output_path)
@mcp.tool(description="Get detailed image metadata including EXIF")
async def image_metadata(
image_path: str = Field(description="Path to image file")
):
"""Get image metadata."""
return await get_image_metadata(image_path)
@mcp.tool(description="Download YouTube video")
async def youtube_download(
url: str = Field(description="YouTube video URL"),
output_dir: str = Field(default=".", description="Output directory"),
max_resolution: str = Field(default="720p", description="Maximum resolution")
):
"""Download YouTube video."""
return await download_youtube_video(url, output_dir, max_resolution)
# ============================================================================
# GOOGLE SEARCH ENHANCED TOOLS
# ============================================================================
@mcp.tool(description="Search Google with API or DuckDuckGo fallback")
async def google_search_enhanced(
query: str = Field(description="Search query"),
num_results: int = Field(default=5, description="Number of results (1-10)"),
safe_search: bool = Field(default=True, description="Enable safe search"),
language: str = Field(default="en", description="Language code"),
country: str = Field(default="us", description="Country code")
):
"""Enhanced Google search."""
return await google_search_api(query, num_results, safe_search, language, country)
@mcp.tool(description="Read and extract content from webpage")
async def webpage_read_enhanced(
url: str = Field(description="URL to read"),
extract_links: bool = Field(default=False, description="Extract links from page")
):
"""Read webpage content."""
return await read_webpage_content(url, extract_links)
# ============================================================================
# WIKIPEDIA ENHANCED TOOLS
# ============================================================================
@mcp.tool(description="Get full Wikipedia article content")
async def wiki_article_full(
title: str = Field(description="Article title"),
language: str = Field(default="en", description="Language code")
):
"""Get full Wikipedia article."""
return await get_article_content(title, language)
@mcp.tool(description="Get Wikipedia article categories")
async def wiki_article_categories(
title: str = Field(description="Article title"),
language: str = Field(default="en", description="Language code")
):
"""Get article categories."""
return await get_article_categories(title, language)
@mcp.tool(description="Get links from Wikipedia article")
async def wiki_article_links(
title: str = Field(description="Article title"),
language: str = Field(default="en", description="Language code")
):
"""Get article links."""
return await get_article_links(title, language)
@mcp.tool(description="Get historical version of Wikipedia article")
async def wiki_article_history(
title: str = Field(description="Article title"),
date: str = Field(description="Date (YYYY/MM/DD)"),
language: str = Field(default="en", description="Language code")
):
"""Get historical Wikipedia article."""
return await get_article_history(title, date, language)
# ============================================================================
# ARXIV ENHANCED TOOLS
# ============================================================================
@mcp.tool(description="Get detailed ArXiv paper information")
async def arxiv_paper_details(
paper_id: str = Field(description="ArXiv paper ID")
):
"""Get paper details."""
return await get_paper_details(paper_id)
@mcp.tool(description="Download ArXiv paper PDF")
async def arxiv_download(
paper_id: str = Field(description="ArXiv paper ID"),
download_dir: str = Field(default=".", description="Download directory")
):
"""Download ArXiv paper."""
return await download_paper(paper_id, download_dir)
@mcp.tool(description="Get ArXiv subject categories")
async def arxiv_categories():
"""Get ArXiv categories."""
return await get_arxiv_categories()
# ============================================================================
# WAYBACK ENHANCED TOOLS
# ============================================================================
@mcp.tool(description="Get content from archived webpage")
async def wayback_archived_content(
url: str = Field(description="URL to retrieve"),
timestamp: str = Field(description="Timestamp (YYYYMMDDhhmmss)")
):
"""Get archived webpage content."""
return await get_archived_content(url, timestamp)
# ============================================================================
# PRIVATE DATA SOURCE TOOLS
# ============================================================================
@mcp.tool(description="Get events from Google Calendar")
async def calendar_events(
start_date: str | None = Field(default=None, description="Start date (ISO format)"),
end_date: str | None = Field(default=None, description="End date (ISO format)"),
calendar_id: str = Field(default="primary", description="Calendar ID"),
max_results: int = Field(default=10, description="Maximum events")
):
"""Get calendar events."""
return await get_calendar_events(start_date, end_date, calendar_id, max_results)
@mcp.tool(description="Search Notion workspace")
async def notion_search(
query: str = Field(description="Search query"),
database_id: str | None = Field(default=None, description="Specific database ID"),
page_size: int = Field(default=10, description="Results per page")
):
"""Search Notion."""
return await search_notion(query, database_id, page_size)
# Complete the 56 native schema descriptions before adding the expanded
# catalog. The implementations and native parameter schemas remain unchanged.
enrich_existing_tools(mcp)
# Experiment 4-7 requires 120+ tools from this perception MCP server. The
# 56 native tools plus 70 additional real-backed, read-mostly tools bring the
# server catalog to 126 tools. Registration is dynamic only to avoid repetitive
# wrapper functions; tools/list still
# returns ordinary full JSON schemas and every tool is callable over MCP.
register_expanded_tools(mcp)
# ============================================================================
# RUN SERVER
# ============================================================================
if __name__ == "__main__":
logging.info("Starting Perception Tools MCP server!")
mcp.run(transport="stdio")
@@ -0,0 +1,858 @@
"""
Media processing tools for audio, image, and video.
Based on AWorld MCP server implementation.
"""
import json
import logging
import traceback
import subprocess
import base64
import os
import time
from pathlib import Path
from typing import Union, Dict, Any
import cv2
from PIL import Image
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse, validate_file_path
load_dotenv()
def _map_model_for_openrouter(model: str) -> str:
"""Map a plain model id onto OpenRouter's `provider/model` form."""
if "/" in model:
return model
m = model.lower()
if m.startswith(("gpt-", "o1-", "o3-", "o4-")):
return f"openai/{model}"
if m.startswith("claude-"):
return "anthropic/claude-opus-4.8"
return model
def _make_vision_client(default_model: str = "gpt-5.6-luna"):
"""Build an OpenAI-compatible vision client with a universal fallback.
Preferred path uses OPENAI_API_KEY directly. When it is absent but an
OPENROUTER_API_KEY is set, transparently route through OpenRouter (mapping
the model id to provider/model form) so the vision tools still run.
Returns (client, model). Raises ValueError with the accepted keys listed
when neither credential is available.
"""
import os
from openai import OpenAI
provider = os.getenv("PERCEPTION_VISION_PROVIDER", "").strip().lower()
if provider == "dashscope":
dashscope_key = os.getenv("DASHSCOPE_API_KEY")
if not dashscope_key:
raise ValueError(
"PERCEPTION_VISION_PROVIDER=dashscope requires DASHSCOPE_API_KEY"
)
client = OpenAI(
api_key=dashscope_key,
# The provided project credential is issued for the international
# Model Studio region; regional keys are not interchangeable.
base_url=os.getenv(
"DASHSCOPE_BASE_URL",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
),
timeout=120.0,
max_retries=0,
)
return client, os.getenv("PERCEPTION_VISION_MODEL", "qwen-vl-max")
gemini_key = os.getenv("GEMINI_API_KEY")
if provider == "gemini":
if not gemini_key:
raise ValueError(
"PERCEPTION_VISION_PROVIDER=gemini requires GEMINI_API_KEY"
)
client = OpenAI(
api_key=gemini_key,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
)
model = os.getenv("PERCEPTION_VISION_MODEL", "gemini-2.5-flash")
return client, model
model = os.getenv("PERCEPTION_VISION_MODEL", default_model)
or_key = os.getenv("OPENROUTER_API_KEY")
# gpt-5.x (incl. gpt-5.6*) needs OpenAI org-verification on the direct API;
# when an OpenRouter key is present, prefer routing these ids through it.
if or_key and model.lower().startswith("gpt-5"):
client = OpenAI(api_key=or_key, base_url="https://openrouter.ai/api/v1")
return client, _map_model_for_openrouter(model)
api_key = os.getenv("OPENAI_API_KEY")
if api_key:
base_url = os.getenv("OPENAI_BASE_URL")
client = OpenAI(api_key=api_key, base_url=base_url) if base_url else OpenAI(api_key=api_key)
return client, model
if or_key:
client = OpenAI(api_key=or_key, base_url="https://openrouter.ai/api/v1")
return client, _map_model_for_openrouter(model)
if gemini_key:
client = OpenAI(
api_key=gemini_key,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
)
return client, os.getenv("PERCEPTION_VISION_MODEL", "gemini-2.5-flash")
raise ValueError(
"No vision key configured. Set OPENAI_API_KEY, OPENROUTER_API_KEY, or GEMINI_API_KEY."
)
async def transcribe_audio_whisper(
file_path: str,
model_size: str = "base",
language: str = "en"
) -> Union[str, TextContent]:
"""
Transcribe audio file using OpenAI Whisper (local).
Note: Requires whisper package installed.
Args:
file_path: Path to audio file
model_size: Whisper model size (tiny, base, small, medium, large)
language: Language code
Returns:
TextContent with transcription
"""
try:
path = validate_file_path(file_path)
logging.info(f"🎤 Transcribing audio: {path}")
try:
import whisper
# Load model
model = whisper.load_model(model_size)
# Transcribe
result = model.transcribe(str(path), language=language)
transcription = result["text"]
response_data = {
"file_name": path.name,
"file_type": path.suffix,
"model": model_size,
"language": language,
"transcription": transcription,
"word_count": len(transcription.split())
}
logging.info(f"✅ Transcribed: {len(transcription)} chars")
action_response = ActionResponse(
success=True,
message=response_data,
metadata={"file_path": str(path)}
)
except ImportError:
# Fallback: try using OpenAI API if available
import os
from openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ImportError("Whisper not installed and no OPENAI_API_KEY found")
client = OpenAI(api_key=api_key)
with open(path, "rb") as audio_file:
transcription = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language=language
)
response_data = {
"file_name": path.name,
"file_type": path.suffix,
"model": "whisper-1 (API)",
"language": language,
"transcription": transcription.text,
"word_count": len(transcription.text.split())
}
action_response = ActionResponse(
success=True,
message=response_data,
metadata={"file_path": str(path), "method": "openai_api"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Audio transcription failed: {str(e)}"
logging.error(f"Audio error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "audio_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_audio_metadata(
file_path: str
) -> Union[str, TextContent]:
"""
Extract audio file metadata using ffprobe.
Args:
file_path: Path to audio file
Returns:
TextContent with audio metadata
"""
try:
path = validate_file_path(file_path)
logging.info(f"🎵 Extracting audio metadata: {path}")
# Use ffprobe to get metadata
cmd = [
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
str(path)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
metadata = json.loads(result.stdout)
format_info = metadata.get("format", {})
streams = metadata.get("streams", [])
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
response_data = {
"file_name": path.name,
"file_size": path.stat().st_size,
"duration": float(format_info.get("duration", 0)),
"bit_rate": int(format_info.get("bit_rate", 0)),
"format": format_info.get("format_name"),
"codec": audio_stream.get("codec_name"),
"sample_rate": int(audio_stream.get("sample_rate", 0)) if audio_stream.get("sample_rate") else None,
"channels": int(audio_stream.get("channels", 0)) if audio_stream.get("channels") else None
}
logging.info(f"✅ Audio metadata extracted")
action_response = ActionResponse(
success=True,
message=response_data,
metadata={"file_path": str(path)}
)
else:
raise RuntimeError(f"ffprobe failed: {result.stderr}")
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Audio metadata extraction failed: {str(e)}"
logging.error(f"Audio metadata error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "audio_metadata_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_text_ocr(
image_path: str,
language: str = "eng"
) -> Union[str, TextContent]:
"""
Extract text from image using OCR.
Args:
image_path: Path to image file
language: OCR language (eng, chi_sim, etc.)
Returns:
TextContent with extracted text
"""
try:
path = validate_file_path(image_path)
logging.info(f"🔍 OCR extracting from image: {path}")
try:
import pytesseract
img = Image.open(path)
text = pytesseract.image_to_string(img, lang=language)
result = {
"file_name": path.name,
"image_size": img.size,
"extracted_text": text,
"text_length": len(text),
"word_count": len(text.split()),
"language": language,
"method": "pytesseract"
}
logging.info(f"✅ OCR extracted: {len(text)} chars")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
except ImportError:
# Fallback to a simpler method or error
raise ImportError("pytesseract not installed. Install with: pip install pytesseract")
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"OCR extraction failed: {str(e)}"
logging.error(f"OCR error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "ocr_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def analyze_image_ai(
image_path: str,
prompt: str = "Describe this image in detail"
) -> Union[str, TextContent]:
"""
Analyze image using AI (OpenAI Vision API).
Args:
image_path: Path to image file
prompt: Prompt for AI analysis
Returns:
TextContent with AI analysis
"""
try:
path = validate_file_path(image_path)
logging.info(f"🤖 AI analyzing image: {path}")
client, model = _make_vision_client()
# Encode image
with open(path, "rb") as img_file:
img_base64 = base64.b64encode(img_file.read()).decode('utf-8')
# Call Vision API
started = time.perf_counter()
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_base64}"
}
}
]
}
],
max_tokens=500
)
latency_seconds = round(time.perf_counter() - started, 3)
analysis = response.choices[0].message.content
usage = getattr(response, "usage", None)
result = {
"file_name": path.name,
"prompt": prompt,
"analysis": analysis,
"model": model,
"provider_receipt": {
"provider": os.getenv("PERCEPTION_VISION_PROVIDER", "auto"),
"response_id": getattr(response, "id", None),
"response_model": getattr(response, "model", model),
"finish_reason": getattr(response.choices[0], "finish_reason", None),
"usage": {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
},
"latency_seconds": latency_seconds,
},
}
logging.info(f"✅ AI analysis completed")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"AI image analysis failed: {str(e)}"
logging.error(f"AI analysis error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "ai_analysis_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_video_keyframes(
video_path: str,
num_frames: int = 10
) -> Union[str, TextContent]:
"""
Extract keyframes from video.
Args:
video_path: Path to video file
num_frames: Number of keyframes to extract
Returns:
TextContent with keyframe information
"""
try:
path = validate_file_path(video_path)
num_frames = max(1, num_frames)
logging.info(f"🎬 Extracting keyframes from video: {path}")
video = cv2.VideoCapture(str(path))
fps = video.get(cv2.CAP_PROP_FPS)
frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
duration = frame_count / fps if fps > 0 else 0
# Calculate frame interval
interval = max(1, frame_count // num_frames)
keyframes = []
frame_num = 0
while len(keyframes) < num_frames and video.isOpened():
ret, frame = video.read()
if not ret:
break
if frame_num % interval == 0:
timestamp = frame_num / fps if fps > 0 else 0
keyframes.append({
"frame_number": frame_num,
"timestamp": round(timestamp, 2),
"shape": frame.shape if frame is not None else None
})
frame_num += 1
video.release()
result = {
"file_name": path.name,
"duration": duration,
"total_frames": frame_count,
"fps": fps,
"keyframes_extracted": len(keyframes),
"keyframes": keyframes
}
logging.info(f"✅ Extracted {len(keyframes)} keyframes")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Keyframe extraction failed: {str(e)}"
logging.error(f"Video error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "video_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def analyze_video_ai(
video_path: str,
num_frames: int = 5,
prompt: str = "Analyze this video and describe what's happening"
) -> Union[str, TextContent]:
"""
Analyze video content using AI vision on keyframes.
Args:
video_path: Path to video file
num_frames: Number of frames to analyze
prompt: Analysis prompt for AI
Returns:
TextContent with AI analysis
"""
video = None
try:
path = validate_file_path(video_path)
num_frames = max(1, num_frames)
logging.info(f"🤖 AI analyzing video: {path}")
client, model = _make_vision_client()
# Extract keyframes
video = cv2.VideoCapture(str(path))
fps = video.get(cv2.CAP_PROP_FPS)
frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
interval = max(1, frame_count // num_frames)
# Extract and encode frames
frame_analyses = []
frame_num = 0
frames_analyzed = 0
while frames_analyzed < num_frames and video.isOpened():
ret, frame = video.read()
if not ret:
break
if frame_num % interval == 0:
# Encode frame
_, buffer = cv2.imencode('.jpg', frame)
img_base64 = base64.b64encode(buffer).decode('utf-8')
# Analyze with GPT-4 Vision
started = time.perf_counter()
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": f"{prompt} (Frame {frames_analyzed + 1}/{num_frames})"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}
}
]
}
],
max_tokens=300
)
latency_seconds = round(time.perf_counter() - started, 3)
timestamp = frame_num / fps if fps > 0 else 0
analysis = response.choices[0].message.content
usage = getattr(response, "usage", None)
frame_analyses.append({
"frame_number": frame_num,
"timestamp": round(timestamp, 2),
"analysis": analysis,
"provider_receipt": {
"provider": os.getenv("PERCEPTION_VISION_PROVIDER", "auto"),
"response_id": getattr(response, "id", None),
"response_model": getattr(response, "model", model),
"finish_reason": getattr(response.choices[0], "finish_reason", None),
"usage": {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
},
"latency_seconds": latency_seconds,
},
})
frames_analyzed += 1
frame_num += 1
# Generate overall summary
combined_analyses = "\n\n".join([f"Frame {i+1} (t={a['timestamp']}s): {a['analysis']}"
for i, a in enumerate(frame_analyses)])
result = {
"file_name": path.name,
"frames_analyzed": len(frame_analyses),
"analyses": frame_analyses,
"combined_analysis": combined_analyses
}
logging.info(f"✅ Analyzed {len(frame_analyses)} frames")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Video analysis failed: {str(e)}"
logging.error(f"Video analysis error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "video_analysis_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
finally:
# Release the native decoder/file handle even when a per-frame Vision
# API call raises mid-loop (the most likely failure point), otherwise
# the VideoCapture leaks until GC finalizes it.
if video is not None:
video.release()
async def trim_audio(
audio_path: str,
start_time: float,
duration: float | None = None,
output_path: str | None = None
) -> Union[str, TextContent]:
"""
Trim audio file to specified time range using ffmpeg.
Args:
audio_path: Path to audio file
start_time: Start time in seconds
duration: Duration in seconds (None for trim to end)
output_path: Output file path (None for auto-generate)
Returns:
TextContent with trimmed audio info
"""
try:
path = validate_file_path(audio_path)
logging.info(f"✂️ Trimming audio: {path}")
# Generate output path if not provided
if output_path is None:
output_path = str(path.parent / f"{path.stem}_trimmed{path.suffix}")
# Build ffmpeg command
cmd = ["ffmpeg", "-i", str(path), "-ss", str(start_time)]
if duration is not None:
cmd.extend(["-t", str(duration)])
cmd.extend(["-c", "copy", "-y", output_path])
# Execute ffmpeg
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {result.stderr}")
output_file = Path(output_path)
response_data = {
"input_file": str(path),
"output_file": str(output_file),
"start_time": start_time,
"duration": duration,
"file_size": output_file.stat().st_size if output_file.exists() else 0
}
logging.info(f"✅ Trimmed audio saved to: {output_file}")
action_response = ActionResponse(
success=True,
message=response_data,
metadata={"output_path": str(output_file)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Audio trim failed: {str(e)}"
logging.error(f"Audio trim error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "audio_trim_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_image_metadata(
image_path: str
) -> Union[str, TextContent]:
"""
Get detailed image metadata including EXIF data.
Args:
image_path: Path to image file
Returns:
TextContent with image metadata
"""
try:
path = validate_file_path(image_path)
logging.info(f"📷 Getting image metadata: {path}")
img = Image.open(path)
# Basic metadata
metadata = {
"file_name": path.name,
"format": img.format,
"mode": img.mode,
"size": img.size,
"width": img.width,
"height": img.height,
"file_size": path.stat().st_size
}
# Try to get EXIF data
try:
from PIL.ExifTags import TAGS
exif_data = {}
if hasattr(img, '_getexif') and img._getexif():
exif = img._getexif()
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
exif_data[tag] = str(value)
if exif_data:
metadata["exif"] = exif_data
except Exception as e:
logging.debug(f"No EXIF data: {e}")
# Image info
if hasattr(img, 'info'):
# PIL's img.info routinely carries non-JSON-serializable values --
# most notably the raw ICC color profile / EXIF blob as `bytes`
# (present in almost every real photo, screenshot or design export).
# Copying them verbatim would make the final json.dumps() raise
# TypeError, turning a valid image into a failure response. Summarize
# bytes as a size marker so metadata extraction still succeeds.
metadata["info"] = {
k: (f"<{len(v)} bytes>" if isinstance(v, (bytes, bytearray)) else v)
for k, v in img.info.items()
}
result = {
"metadata": metadata,
"has_exif": "exif" in metadata
}
logging.info(f"✅ Image metadata extracted")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Metadata extraction failed: {str(e)}"
logging.error(f"Metadata error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "metadata_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,583 @@
"""
Multimodal understanding tools: web, documents, images, and videos.
"""
import json
import logging
import os
import traceback
from pathlib import Path
from typing import Optional, Union
import base64
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from mcp.types import TextContent
from pydantic import Field
from base import ActionResponse, validate_file_path, download_file_from_url, is_url
load_dotenv()
async def read_webpage(
url: str,
extract_text: bool = True,
extract_links: bool = False
) -> Union[str, TextContent]:
"""
Read and extract content from a webpage.
Args:
url: URL of the webpage
extract_text: Whether to extract main text content
extract_links: Whether to extract all links
Returns:
TextContent with extracted webpage content
"""
try:
logging.info(f"📄 Reading webpage: {url}")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
result = {
"url": url,
"title": soup.title.string if soup.title else "No title"
}
if extract_text:
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
text = soup.get_text()
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
text = ' '.join(chunk for chunk in chunks if chunk)
result["text"] = text[:5000] # Limit to first 5000 chars
result["text_length"] = len(text)
if extract_links:
links = []
for link in soup.find_all('a', href=True):
links.append({
"text": link.get_text().strip(),
"href": link['href']
})
result["links"] = links[:50] # Limit to first 50 links
logging.info(f"✅ Successfully extracted webpage content")
action_response = ActionResponse(
success=True,
message=result,
metadata={"url": url}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Webpage reading failed: {str(e)}"
logging.error(f"Webpage error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "webpage_error", "url": url}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
def _sniff_document_type(path: Path) -> Optional[str]:
"""Detect .pdf/.docx/.pptx from magic bytes when the extension is unusable.
A URL's path often carries no real extension (e.g.
https://arxiv.org/pdf/2301.07041 -> ".07041"), so the downloaded temp
file's suffix cannot be trusted to identify the format.
"""
try:
with open(path, 'rb') as f:
header = f.read(4)
if header.startswith(b'%PDF'):
return '.pdf'
if header.startswith(b'PK\x03\x04'):
import zipfile
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
if any(n.startswith('word/') for n in names):
return '.docx'
if any(n.startswith('ppt/') for n in names):
return '.pptx'
except Exception as e:
logging.debug(f"Document type sniffing failed: {e}")
return None
async def read_document(
file_path: str,
extract_images: bool = False
) -> Union[str, TextContent]:
"""
Read and extract content from documents (PDF, DOCX, PPTX).
Args:
file_path: Path to the document file (or URL)
extract_images: Whether to extract images from document
Returns:
TextContent with extracted document content
"""
try:
# Handle URL downloads
if is_url(file_path):
logging.info(f"📥 Downloading document from URL")
temp_path, _ = download_file_from_url(file_path)
file_path = temp_path
path = validate_file_path(file_path)
logging.info(f"📄 Reading document: {path}")
file_ext = path.suffix.lower()
if file_ext not in ('.pdf', '.docx', '.pptx'):
# The suffix came from the URL path and may be meaningless
# (".07041", ".tmp"), so fall back to the file's magic bytes.
file_ext = _sniff_document_type(path) or file_ext
# PDF extraction
if file_ext == '.pdf':
import PyPDF2
with open(path, 'rb') as file:
reader = PyPDF2.PdfReader(file)
text = ""
for page in reader.pages:
text += page.extract_text() + "\n"
result = {
"file_name": path.name,
"file_type": "pdf",
"page_count": len(reader.pages),
"text": text[:10000], # Limit size
"text_length": len(text)
}
# DOCX extraction
elif file_ext == '.docx':
from docx import Document
doc = Document(path)
text = "\n".join([para.text for para in doc.paragraphs])
result = {
"file_name": path.name,
"file_type": "docx",
"paragraph_count": len(doc.paragraphs),
"text": text[:10000],
"text_length": len(text)
}
# PPTX extraction
elif file_ext == '.pptx':
from pptx import Presentation
prs = Presentation(path)
text = ""
for slide in prs.slides:
for shape in slide.shapes:
if hasattr(shape, "text"):
text += shape.text + "\n"
result = {
"file_name": path.name,
"file_type": "pptx",
"slide_count": len(prs.slides),
"text": text[:10000],
"text_length": len(text)
}
else:
raise ValueError(f"Unsupported file type: {file_ext}")
logging.info(f"✅ Successfully extracted document content")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path), "file_type": file_ext}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Document reading failed: {str(e)}"
logging.error(f"Document error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "document_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def parse_image(
image_path: str,
use_llm: bool = True
) -> Union[str, TextContent]:
"""
Parse and understand image content.
Args:
image_path: Path to image file or URL
use_llm: Whether to use LLM for image understanding
Returns:
TextContent with image analysis
"""
try:
# Handle URL downloads
if is_url(image_path):
logging.info(f"📥 Downloading image from URL")
temp_path, _ = download_file_from_url(image_path)
image_path = temp_path
path = validate_file_path(image_path)
logging.info(f"🖼️ Parsing image: {path}")
from PIL import Image
img = Image.open(path)
result = {
"file_name": path.name,
"format": img.format,
"mode": img.mode,
"size": img.size,
"width": img.width,
"height": img.height
}
# If LLM analysis requested, encode image for vision API
if use_llm:
with open(path, 'rb') as img_file:
img_base64 = base64.b64encode(img_file.read()).decode('utf-8')
result["base64_data"] = img_base64[:100] + "..." # Truncated for display
result["note"] = "Full base64 data available for vision API analysis"
logging.info(f"✅ Successfully parsed image")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Image parsing failed: {str(e)}"
logging.error(f"Image error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "image_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def parse_video(
video_path: str,
extract_frames: bool = False,
frame_interval: int = 30
) -> Union[str, TextContent]:
"""
Parse and extract information from video files.
Args:
video_path: Path to video file or URL
extract_frames: Whether to extract sample frames
frame_interval: Extract one frame every N seconds
Returns:
TextContent with video metadata
"""
try:
# Handle URL downloads
if is_url(video_path):
logging.info(f"📥 Downloading video from URL")
temp_path, _ = download_file_from_url(video_path, max_size_mb=500)
video_path = temp_path
path = validate_file_path(video_path)
logging.info(f"🎥 Parsing video: {path}")
import cv2
video = cv2.VideoCapture(str(path))
fps = video.get(cv2.CAP_PROP_FPS)
frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = frame_count / fps if fps > 0 else 0
result = {
"file_name": path.name,
"duration_seconds": duration,
"fps": fps,
"frame_count": frame_count,
"resolution": f"{width}x{height}",
"width": width,
"height": height
}
video.release()
logging.info(f"✅ Successfully parsed video metadata")
action_response = ActionResponse(
success=True,
message=result,
metadata={"file_path": str(path)}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Video parsing failed: {str(e)}"
logging.error(f"Video error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "video_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def download_youtube_video(
url: str,
output_dir: str = ".",
max_resolution: str = "720p"
) -> Union[str, TextContent]:
"""
Download YouTube video using yt-dlp.
Args:
url: YouTube video URL
output_dir: Directory to save video
max_resolution: Maximum resolution (360p, 480p, 720p, 1080p)
Returns:
TextContent with download result
"""
try:
logging.info(f"📥 Downloading YouTube video: {url}")
try:
import yt_dlp
output_template = Path(output_dir) / '%(title)s.%(ext)s'
ydl_opts = {
'format': f'best[height<={max_resolution[:-1]}]',
'outtmpl': str(output_template),
'quiet': False
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
result = {
"title": info['title'],
"duration": info.get('duration'),
"output_dir": output_dir,
"resolution": max_resolution,
"video_id": info['id']
}
logging.info(f"✅ Downloaded: {info['title']}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"url": url}
)
except ImportError:
raise ImportError("yt-dlp not installed. Install with: pip install yt-dlp")
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"YouTube download failed: {str(e)}"
logging.error(f"YouTube download error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "youtube_download_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def extract_youtube_transcript(
video_id: str,
language_code: str = "en",
translate_to_language: str | None = None
) -> Union[str, TextContent]:
"""
Extract transcript from a YouTube video.
Args:
video_id: YouTube video ID or URL
language_code: Language code for the transcript (default: en)
translate_to_language: Translate transcript to this language if provided
Returns:
TextContent with transcript data
"""
try:
from youtube_transcript_api import YouTubeTranscriptApi
# Clean video_id if full URL was provided
if "youtube.com" in video_id or "youtu.be" in video_id:
if "?v=" in video_id:
video_id = video_id.split("?v=")[-1].split("&")[0]
elif "youtu.be/" in video_id:
video_id = video_id.split("youtu.be/")[-1].split("?")[0]
logging.info(f"📺 Extracting transcript for video ID: {video_id}")
# Get transcript using correct API
if translate_to_language:
transcript_list = YouTubeTranscriptApi().list(video_id)
try:
transcript = transcript_list.find_transcript([language_code])
except Exception:
# If specified language not found, get any available transcript
transcript = transcript_list.find_generated_transcript(["en"])
# Translate to target language
fetched_transcript = transcript.translate(translate_to_language).fetch()
transcript_data = fetched_transcript.snippets
else:
try:
# Use fetch method which returns FetchedTranscript
fetched_transcript = YouTubeTranscriptApi().fetch(
video_id,
languages=(language_code,)
)
transcript_data = fetched_transcript.snippets
except Exception:
# Fallback to English
fetched_transcript = YouTubeTranscriptApi().fetch(video_id, languages=("en",))
transcript_data = fetched_transcript.snippets
# Format transcript
formatted_transcript = []
for entry in transcript_data:
# Access as object attributes, not dictionary
start_time = entry.start if hasattr(entry, 'start') else entry.get('start', 0)
text = entry.text if hasattr(entry, 'text') else entry.get('text', '')
minutes, seconds = divmod(int(start_time), 60)
timestamp = f"{minutes:02d}:{seconds:02d}"
formatted_transcript.append({
"timestamp": timestamp,
"text": text
})
# Create full text version
full_text = " ".join([
entry.text if hasattr(entry, 'text') else entry.get('text', '')
for entry in transcript_data
])
result = {
"video_id": video_id,
"language": translate_to_language if translate_to_language else language_code,
"transcript": formatted_transcript[:100], # Limit to first 100 entries
"total_entries": len(transcript_data),
"full_text": full_text[:5000], # Limit full text to 5000 chars
"full_text_length": len(full_text)
}
logging.info(f"✅ Successfully extracted transcript ({len(transcript_data)} entries)")
action_response = ActionResponse(
success=True,
message=result,
metadata={
"video_id": video_id,
"language": language_code,
"translated": translate_to_language is not None
}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"YouTube transcript extraction failed: {str(e)}"
logging.error(f"YouTube error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "youtube_error", "video_id": video_id}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,270 @@
"""
Private data source tools: Google Calendar, Notion.
"""
import json
import logging
import os
import traceback
from datetime import datetime, timedelta
from typing import Union
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse
load_dotenv()
async def get_calendar_events(
start_date: str | None = None,
end_date: str | None = None,
calendar_id: str = "primary",
max_results: int = 10
) -> Union[str, TextContent]:
"""
Get events from Google Calendar.
Args:
start_date: Start date (ISO format, defaults to today)
end_date: End date (ISO format, defaults to 7 days from now)
calendar_id: Calendar ID (default: primary)
max_results: Maximum number of events to return
Returns:
TextContent with calendar events
"""
try:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
import pickle
logging.info(f"📅 Getting calendar events")
# Token file path
token_path = os.path.expanduser("~/.perception-tools/google_token.pickle")
if not os.path.exists(token_path):
action_response = ActionResponse(
success=False,
message="Google Calendar not configured. Please run setup to authenticate.",
metadata={"error_type": "missing_credentials"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
# Load credentials
with open(token_path, 'rb') as token:
creds = pickle.load(token)
# Refresh if expired
if creds.expired and creds.refresh_token:
creds.refresh(Request())
service = build('calendar', 'v3', credentials=creds)
# Set default date range if not provided
if not start_date:
start_date = datetime.utcnow().isoformat() + 'Z'
if not end_date:
end_dt = datetime.utcnow() + timedelta(days=7)
end_date = end_dt.isoformat() + 'Z'
# Query calendar
events_result = service.events().list(
calendarId=calendar_id,
timeMin=start_date,
timeMax=end_date,
maxResults=max_results,
singleEvents=True,
orderBy='startTime'
).execute()
events = events_result.get('items', [])
formatted_events = []
for event in events:
start = event['start'].get('dateTime', event['start'].get('date'))
end = event['end'].get('dateTime', event['end'].get('date'))
formatted_events.append({
"id": event['id'],
"summary": event.get('summary', 'No title'),
"start": start,
"end": end,
"location": event.get('location'),
"description": event.get('description'),
"attendees": [a.get('email') for a in event.get('attendees', [])]
})
logging.info(f"✅ Found {len(formatted_events)} calendar events")
action_response = ActionResponse(
success=True,
message={
"events": formatted_events,
"count": len(formatted_events),
"calendar_id": calendar_id
},
metadata={"start_date": start_date, "end_date": end_date}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except ImportError:
error_msg = "Google Calendar libraries not installed. Install with: pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client"
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "missing_library"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Calendar query failed: {str(e)}"
logging.error(f"Calendar error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "calendar_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_notion(
query: str,
database_id: str | None = None,
page_size: int = 10
) -> Union[str, TextContent]:
"""
Search Notion workspace or specific database.
Args:
query: Search query
database_id: Optional specific database ID
page_size: Number of results per page
Returns:
TextContent with Notion search results
"""
try:
from notion_client import Client
logging.info(f"📝 Searching Notion for: {query}")
api_key = os.getenv("NOTION_API_KEY")
if not api_key:
action_response = ActionResponse(
success=False,
message="Notion API key not configured. Set NOTION_API_KEY.",
metadata={"error_type": "missing_credentials"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
notion = Client(auth=api_key)
if database_id:
# Search specific database
response = notion.databases.query(
database_id=database_id,
filter={
"property": "Name",
"rich_text": {
"contains": query
}
},
page_size=page_size
)
else:
# Search entire workspace
response = notion.search(
query=query,
page_size=page_size
)
results = []
for item in response.get("results", []):
result_data = {
"id": item["id"],
"type": item["object"],
"url": item.get("url"),
"created_time": item.get("created_time"),
"last_edited_time": item.get("last_edited_time")
}
# Extract title/name
if "properties" in item:
for prop_name, prop_value in item["properties"].items():
if prop_value.get("type") == "title" and prop_value.get("title"):
title_parts = [t.get("plain_text", "") for t in prop_value["title"]]
result_data["title"] = "".join(title_parts)
break
results.append(result_data)
logging.info(f"✅ Found {len(results)} Notion items")
action_response = ActionResponse(
success=True,
message={
"query": query,
"results": results,
"count": len(results)
},
metadata={"database_id": database_id}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except ImportError:
error_msg = "Notion SDK not installed. Install with: pip install notion-client"
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "missing_library"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Notion search failed: {str(e)}"
logging.error(f"Notion error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "notion_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,544 @@
"""
PubChem chemical compound data tools.
Based on AWorld MCP server implementation.
"""
import json
import logging
import time
import traceback
from typing import Union, Literal
from urllib.parse import quote, urlsplit, urlunsplit
import requests
from dotenv import load_dotenv
from mcp.types import TextContent
from pydantic import BaseModel, Field
from base import ActionResponse
load_dotenv()
class CompoundData(BaseModel):
"""Structured compound data from PubChem."""
cid: int | None = None
name: str | None = None
molecular_formula: str | None = None
molecular_weight: float | None = None
smiles: str | None = None
inchi: str | None = None
synonyms: list[str] = []
class PubChemMetadata(BaseModel):
"""Metadata for PubChem operation results."""
query_type: str
query_value: str
api_endpoint: str
response_time: float
total_results: int | None = None
rate_limit_delay: float | None = None
error_type: str | None = None
class PubChemClient:
"""PubChem API client with rate limiting."""
def __init__(self):
self.base_url = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
self.request_delay = 0.2 # 200ms delay to stay under 5 req/sec limit
self.last_request_time = 0.0
self.timeout = 30
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "PerceptionToolsMCP/1.0",
"Accept": "application/json"
})
def _rate_limit(self) -> float:
"""Enforce rate limiting to comply with PubChem usage policy."""
current_time = time.time()
time_since_last = current_time - self.last_request_time
if time_since_last < self.request_delay:
delay = self.request_delay - time_since_last
time.sleep(delay)
self.last_request_time = time.time()
return delay
self.last_request_time = current_time
return 0.0
@staticmethod
def _listkey_poll_url(url: str, list_key: str) -> str:
"""Convert an asynchronous PUG REST request into its ListKey poll URL."""
parsed = urlsplit(url)
compound_marker = "/compound/"
compound_index = parsed.path.find(compound_marker)
if compound_index < 0:
raise requests.RequestException("PubChem ListKey response for an unsupported endpoint")
operation_start = compound_index + len(compound_marker)
operation_indexes = [
index
for marker in ("/property/", "/cids/", "/synonyms/")
if (index := parsed.path.find(marker, operation_start)) >= 0
]
if not operation_indexes:
raise requests.RequestException("PubChem ListKey response did not identify a poll operation")
operation_index = min(operation_indexes)
poll_path = (
parsed.path[:operation_start]
+ f"listkey/{quote(list_key, safe='')}"
+ parsed.path[operation_index:]
)
return urlunsplit((parsed.scheme, parsed.netloc, poll_path, parsed.query, parsed.fragment))
def make_request(
self,
url: str,
params: dict = None,
max_retries: int = 12,
_origin_url: str | None = None,
_started_at: float | None = None,
) -> tuple[dict | None, float]:
"""Make a rate-limited request to PubChem API with retry for async operations."""
origin_url = _origin_url or url
started_at = _started_at if _started_at is not None else time.perf_counter()
self._rate_limit()
try:
response = self.session.get(url, params=params, timeout=self.timeout)
if response.status_code == 200:
return response.json(), time.perf_counter() - started_at
elif response.status_code == 202:
# PUG REST returns a ListKey for long-running searches. Polling the
# original URL starts a new job, so switch to the documented
# list-key endpoint and retain the complete request latency.
if max_retries > 0:
waiting = response.json().get("Waiting", {})
list_key = str(waiting.get("ListKey", "")).strip()
if not list_key:
raise requests.RequestException("PubChem async response omitted ListKey")
poll_url = self._listkey_poll_url(url, list_key)
logging.info("PubChem async operation, waiting 2s before polling ListKey")
time.sleep(2)
return self.make_request(
poll_url,
params,
max_retries - 1,
_origin_url=origin_url,
_started_at=started_at,
)
else:
raise requests.RequestException("PubChem async operation timeout after retries")
elif response.status_code in {429, 500, 502, 503, 504} and max_retries > 0:
# A ListKey job can fail independently inside PubChem. Start a
# fresh copy of the original query in that case; otherwise
# retry the same endpoint. All retries remain bounded.
retry_url = origin_url if "/listkey/" in url else url
logging.warning(
"PubChem transient HTTP %s; retrying %s",
response.status_code,
"original query" if retry_url == origin_url else "request",
)
time.sleep(2)
return self.make_request(
retry_url,
params,
max_retries - 1,
_origin_url=origin_url,
_started_at=started_at,
)
else:
raise requests.RequestException(f"HTTP {response.status_code}: {response.text}")
except requests.Timeout:
raise requests.RequestException(f"Request timeout after {self.timeout}s")
except requests.RequestException:
raise
# Global client instance
_client = None
def get_client() -> PubChemClient:
"""Get or create the global PubChem client."""
global _client
if _client is None:
_client = PubChemClient()
return _client
async def search_compounds(
query: str,
search_type: Literal["name", "cid", "smiles", "inchi", "formula"] = "name",
max_results: int = 10
) -> Union[str, TextContent]:
"""
Search for chemical compounds in PubChem database.
Args:
query: Search term or identifier
search_type: Type of search (name, cid, smiles, inchi, formula)
max_results: Maximum number of results (1-100)
Returns:
TextContent with compound search results
"""
try:
if not query or not query.strip():
raise ValueError("Search query is required")
max_results = max(1, min(max_results, 100))
logging.info(f"🔬 Searching PubChem for: {query} (type: {search_type})")
client = get_client()
# Build API URL based on search type
if search_type == "cid":
url = f"{client.base_url}/compound/cid/{quote(str(query))}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
elif search_type == "name":
url = f"{client.base_url}/compound/name/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
elif search_type == "smiles":
url = f"{client.base_url}/compound/smiles/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
elif search_type == "inchi":
url = f"{client.base_url}/compound/inchi/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
elif search_type == "formula":
url = f"{client.base_url}/compound/formula/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
else:
raise ValueError(f"Unsupported search type: {search_type}")
# Make API request
data, response_time = client.make_request(url)
# Parse results
compounds = []
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
properties_list = data["PropertyTable"]["Properties"][:max_results]
for prop in properties_list:
compound = CompoundData(
cid=prop.get("CID"),
name=prop.get("Title"),
molecular_formula=prop.get("MolecularFormula"),
molecular_weight=prop.get("MolecularWeight"),
smiles=prop.get("CanonicalSMILES"),
inchi=prop.get("InChI")
)
compounds.append(compound)
# Format results
result = {
"query": query,
"search_type": search_type,
"compounds": [c.model_dump() for c in compounds],
"count": len(compounds)
}
metadata = PubChemMetadata(
query_type=search_type,
query_value=query,
api_endpoint=url,
response_time=response_time,
total_results=len(compounds)
)
logging.info(f"✅ Found {len(compounds)} compounds ({response_time:.2f}s)")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except ValueError as e:
error_msg = f"Invalid input: {str(e)}"
logging.error(f"PubChem search error: {error_msg}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "invalid_input"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Search failed: {str(e)}"
logging.error(f"PubChem error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "api_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_compound_properties(
cid: int,
properties: list[str] | None = None
) -> Union[str, TextContent]:
"""
Retrieve detailed chemical properties for a PubChem compound.
Args:
cid: PubChem Compound ID
properties: List of property names (e.g., MolecularWeight, XLogP)
Returns:
TextContent with compound properties
"""
try:
if not cid or cid <= 0:
raise ValueError("Valid PubChem CID is required")
if not properties:
properties = [
"MolecularWeight", "MolecularFormula", "CanonicalSMILES",
"InChI", "XLogP", "TPSA", "HBondDonorCount", "HBondAcceptorCount"
]
logging.info(f"🔬 Getting properties for CID: {cid}")
client = get_client()
props_str = ",".join(properties)
url = f"{client.base_url}/compound/cid/{cid}/property/{props_str}/JSON"
data, response_time = client.make_request(url)
compound_props = {}
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
props_data = data["PropertyTable"]["Properties"][0]
compound_props = {k: v for k, v in props_data.items() if k != "CID"}
result = {
"cid": cid,
"properties": compound_props
}
metadata = PubChemMetadata(
query_type="properties",
query_value=str(cid),
api_endpoint=url,
response_time=response_time,
total_results=len(compound_props)
)
logging.info(f"✅ Retrieved {len(compound_props)} properties")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Property retrieval failed: {str(e)}"
logging.error(f"PubChem error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "api_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_compound_synonyms(
cid: int,
max_synonyms: int = 20
) -> Union[str, TextContent]:
"""
Retrieve synonyms for a PubChem compound.
Args:
cid: PubChem Compound ID
max_synonyms: Maximum number of synonyms (1-100)
Returns:
TextContent with compound synonyms
"""
try:
if not cid or cid <= 0:
raise ValueError("Valid PubChem CID is required")
max_synonyms = max(1, min(max_synonyms, 100))
logging.info(f"🔬 Getting synonyms for CID: {cid}")
client = get_client()
url = f"{client.base_url}/compound/cid/{cid}/synonyms/JSON"
data, response_time = client.make_request(url)
synonyms = []
if data and "InformationList" in data and "Information" in data["InformationList"]:
info_list = data["InformationList"]["Information"]
if info_list and "Synonym" in info_list[0]:
synonyms = info_list[0]["Synonym"][:max_synonyms]
result = {
"cid": cid,
"synonyms": synonyms,
"count": len(synonyms)
}
metadata = PubChemMetadata(
query_type="synonyms",
query_value=str(cid),
api_endpoint=url,
response_time=response_time,
total_results=len(synonyms)
)
logging.info(f"✅ Retrieved {len(synonyms)} synonyms")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Synonym retrieval failed: {str(e)}"
logging.error(f"PubChem error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "api_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_similar_compounds(
cid: int,
similarity_threshold: float = 0.9,
max_results: int = 10
) -> Union[str, TextContent]:
"""
Find structurally similar compounds.
Args:
cid: Reference compound CID
similarity_threshold: Minimum similarity (0.0-1.0)
max_results: Maximum results (1-50)
Returns:
TextContent with similar compounds
"""
try:
if not cid or cid <= 0:
raise ValueError("Valid PubChem CID is required")
similarity_threshold = max(0.0, min(similarity_threshold, 1.0))
max_results = max(1, min(max_results, 50))
logging.info(f"🔬 Searching similar compounds to CID: {cid}")
client = get_client()
threshold_percent = int(similarity_threshold * 100)
url = f"{client.base_url}/compound/fastsimilarity_2d/cid/{cid}/property/Title,MolecularFormula,MolecularWeight/JSON"
params = {
"Threshold": threshold_percent,
"MaxRecords": max_results
}
data, response_time = client.make_request(url, params)
similar_compounds = []
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
properties_list = data["PropertyTable"]["Properties"]
for prop in properties_list:
if prop.get("CID") != cid:
compound = CompoundData(
cid=prop.get("CID"),
name=prop.get("Title"),
molecular_formula=prop.get("MolecularFormula"),
molecular_weight=prop.get("MolecularWeight")
)
similar_compounds.append(compound)
result = {
"reference_cid": cid,
"similarity_threshold": similarity_threshold,
"similar_compounds": [c.model_dump() for c in similar_compounds],
"count": len(similar_compounds)
}
metadata = PubChemMetadata(
query_type="similarity",
query_value=str(cid),
api_endpoint=url,
response_time=response_time,
total_results=len(similar_compounds)
)
logging.info(f"✅ Found {len(similar_compounds)} similar compounds")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Similarity search failed: {str(e)}"
logging.error(f"PubChem error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "api_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,862 @@
"""
Public data source tools: weather, stocks, currency, Wiki, ArXiv, Wayback Machine.
"""
import json
import logging
import os
import time
import traceback
from datetime import datetime
from typing import Union
import requests
from dotenv import load_dotenv
from mcp.types import TextContent
from pydantic import BaseModel, Field
import wikipedia
from base import ActionResponse
load_dotenv()
async def get_weather(
location: str,
latitude: float | None = None,
longitude: float | None = None
) -> Union[str, TextContent]:
"""
Get current weather information for a location using Open-Meteo API.
Args:
location: City name for display purposes
latitude: Latitude coordinate (if not provided, will try to geocode location)
longitude: Longitude coordinate (if not provided, will try to geocode location)
Returns:
TextContent with weather data
"""
try:
logging.info(f"🌤️ Getting weather for: {location}")
# If coordinates not provided, try to geocode the location
if latitude is None or longitude is None:
# Use Open-Meteo's geocoding API
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
geocode_params = {
"name": location,
"count": 1,
"language": "en",
"format": "json"
}
geocode_response = requests.get(geocode_url, params=geocode_params, timeout=10)
geocode_response.raise_for_status()
geocode_data = geocode_response.json()
if not geocode_data.get("results"):
raise ValueError(f"Location not found: {location}")
first_result = geocode_data["results"][0]
latitude = first_result["latitude"]
longitude = first_result["longitude"]
location = first_result.get("name", location)
country = first_result.get("country", "")
else:
country = ""
# Get weather data from Open-Meteo
weather_url = "https://api.open-meteo.com/v1/forecast"
weather_params = {
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m",
"timezone": "auto"
}
response = requests.get(weather_url, params=weather_params, timeout=10)
response.raise_for_status()
data = response.json()
current = data["current"]
# Map weather codes to descriptions
# Based on WMO Weather interpretation codes
weather_codes = {
0: "Clear sky",
1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Foggy", 48: "Depositing rime fog",
51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle",
61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
85: "Slight snow showers", 86: "Heavy snow showers",
95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail"
}
weather_code = current["weather_code"]
description = weather_codes.get(weather_code, "Unknown")
result = {
"location": location,
"country": country,
"latitude": latitude,
"longitude": longitude,
"temperature": current["temperature_2m"],
"feels_like": current["apparent_temperature"],
"humidity": current["relative_humidity_2m"],
"precipitation": current["precipitation"],
"weather_code": weather_code,
"description": description,
"wind_speed": current["wind_speed_10m"],
"wind_direction": current["wind_direction_10m"],
"units": "metric",
"timestamp": current["time"],
"provider": "Open-Meteo"
}
logging.info(f"✅ Weather: {result['temperature']}°C - {result['description']}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"location": location, "provider": "Open-Meteo", "api_key_required": False}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Weather query failed: {str(e)}"
logging.error(f"Weather error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "weather_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_stock_price(
symbol: str,
interval: str = "1d"
) -> Union[str, TextContent]:
"""
Get stock price information.
Args:
symbol: Stock ticker symbol (e.g., AAPL, TSLA)
interval: Data interval (1d, 1h, etc.)
Returns:
TextContent with stock data
"""
try:
logging.info(f"📈 Getting stock price for: {symbol}")
# Using Yahoo Finance API (free, no key required)
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
params = {
"interval": interval,
"range": "1d"
}
headers = {
"User-Agent": "Mozilla/5.0"
}
response = requests.get(url, params=params, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
if "chart" in data and "result" in data["chart"] and data["chart"]["result"]:
quote = data["chart"]["result"][0]["meta"]
result = {
"symbol": symbol,
"currency": quote.get("currency", "USD"),
"current_price": quote.get("regularMarketPrice"),
"previous_close": quote.get("previousClose"),
"open": quote.get("regularMarketOpen"),
"day_high": quote.get("regularMarketDayHigh"),
"day_low": quote.get("regularMarketDayLow"),
"volume": quote.get("regularMarketVolume"),
"exchange": quote.get("exchangeName")
}
logging.info(f"✅ Stock price: ${result['current_price']}")
else:
raise ValueError(f"Invalid response for symbol: {symbol}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"symbol": symbol}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Stock query failed: {str(e)}"
logging.error(f"Stock error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "stock_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def convert_currency(
amount: float,
from_currency: str,
to_currency: str
) -> Union[str, TextContent]:
"""
Convert between currencies.
Args:
amount: Amount to convert
from_currency: Source currency code (e.g., USD)
to_currency: Target currency code (e.g., EUR)
Returns:
TextContent with conversion result
"""
try:
logging.info(f"💱 Converting {amount} {from_currency} to {to_currency}")
# Using free exchange rate API
url = f"https://api.exchangerate-api.com/v4/latest/{from_currency}"
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
if to_currency not in data["rates"]:
raise ValueError(f"Currency not found: {to_currency}")
rate = data["rates"][to_currency]
converted_amount = amount * rate
result = {
"amount": amount,
"from_currency": from_currency,
"to_currency": to_currency,
"exchange_rate": rate,
"converted_amount": converted_amount,
"timestamp": data.get("date", datetime.now().isoformat())
}
logging.info(f"{amount} {from_currency} = {converted_amount:.2f} {to_currency}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"rate": rate}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Currency conversion failed: {str(e)}"
logging.error(f"Currency error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "currency_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_wikipedia(
query: str,
language: str = "en",
sentences: int = 5
) -> Union[str, TextContent]:
"""
Search Wikipedia and get article summary.
Args:
query: Search query
language: Wikipedia language (en, zh, etc.)
sentences: Number of sentences in summary
Returns:
TextContent with Wikipedia article
"""
try:
wikipedia.set_lang(language)
logging.info(f"📚 Searching Wikipedia for: {query}")
# Search for pages
search_results = wikipedia.search(query, results=3)
if not search_results:
raise ValueError(f"No Wikipedia articles found for: {query}")
# Get the first result's page
page = wikipedia.page(search_results[0], auto_suggest=False)
summary = wikipedia.summary(search_results[0], sentences=sentences, auto_suggest=False)
result = {
"title": page.title,
"url": page.url,
"summary": summary,
"language": language,
"search_results": search_results
}
logging.info(f"✅ Found Wikipedia article: {page.title}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"query": query, "language": language}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Wikipedia search failed: {str(e)}"
logging.error(f"Wikipedia error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "wikipedia_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_arxiv(
query: str,
max_results: int = 5,
sort_by: str = "relevance"
) -> Union[str, TextContent]:
"""
Search ArXiv for academic papers.
Args:
query: Search query
max_results: Maximum number of results
sort_by: Sort method (relevance, lastUpdatedDate, submittedDate)
Returns:
TextContent with ArXiv papers
"""
try:
import arxiv
logging.info(f"🔬 Searching ArXiv for: {query}")
# Map sort_by to arxiv.SortCriterion
sort_map = {
"relevance": arxiv.SortCriterion.Relevance,
"lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate,
"submittedDate": arxiv.SortCriterion.SubmittedDate
}
sort_criterion = sort_map.get(sort_by, arxiv.SortCriterion.Relevance)
search = arxiv.Search(
query=query,
max_results=max_results,
sort_by=sort_criterion
)
# arxiv.Client defaults to page_size=100 even when the caller asks for
# only a handful of papers. That needlessly expands the official API
# request and made paired experiment arms much more susceptible to
# export.arxiv.org throttling. Keep the request page bounded by the
# public MCP argument while retaining the library's documented delay
# and retry behavior.
client = arxiv.Client(
page_size=max(1, min(max_results, 100)),
delay_seconds=3.0,
num_retries=3,
)
papers = []
for result in client.results(search):
papers.append({
"title": result.title,
"authors": [author.name for author in result.authors],
"summary": result.summary[:500] + "...",
"published": result.published.isoformat(),
"url": result.entry_id,
"pdf_url": result.pdf_url,
"categories": result.categories
})
logging.info(f"✅ Found {len(papers)} papers")
action_response = ActionResponse(
success=True,
message={
"query": query,
"papers": papers,
"count": len(papers)
},
metadata={"query": query, "max_results": max_results}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"ArXiv search failed: {str(e)}"
logging.error(f"ArXiv error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "arxiv_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_wayback(
url: str,
year: int | None = None,
limit: int = 10
) -> Union[str, TextContent]:
"""
Search Wayback Machine for archived versions of a URL.
Args:
url: URL to search for
year: Optional year to filter results
limit: Maximum number of snapshots to return
Returns:
TextContent with archived snapshots
"""
try:
logging.info(f"🕰️ Searching Wayback Machine for: {url}")
# CDX API endpoint
cdx_url = "http://web.archive.org/cdx/search/cdx"
params = {
"url": url,
"output": "json",
"limit": limit,
"fl": "timestamp,original,statuscode,mimetype"
}
if year:
params["from"] = f"{year}0101"
params["to"] = f"{year}1231"
response = requests.get(cdx_url, params=params, timeout=30)
response.raise_for_status()
data = response.json()
# First row is headers
if len(data) <= 1:
raise ValueError(f"No archived snapshots found for: {url}")
headers = data[0]
snapshots = []
for row in data[1:]:
snapshot = dict(zip(headers, row))
# Convert timestamp to readable format
ts = snapshot["timestamp"]
dt = datetime.strptime(ts, "%Y%m%d%H%M%S")
snapshots.append({
"timestamp": dt.isoformat(),
"url": f"https://web.archive.org/web/{ts}/{snapshot['original']}",
"status_code": snapshot.get("statuscode"),
"mime_type": snapshot.get("mimetype")
})
logging.info(f"✅ Found {len(snapshots)} archived snapshots")
action_response = ActionResponse(
success=True,
message={
"url": url,
"snapshots": snapshots,
"count": len(snapshots)
},
metadata={"url": url, "year": year}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Wayback Machine search failed: {str(e)}"
logging.error(f"Wayback error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "wayback_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_crypto_price(
symbol: str,
vs_currency: str = "usd"
) -> Union[str, TextContent]:
"""
Get cryptocurrency price information using CoinGecko API (free, no API key required).
Args:
symbol: Cryptocurrency symbol or ID (e.g., bitcoin, ethereum, btc, eth)
vs_currency: Target currency (usd, eur, gbp, etc.)
Returns:
TextContent with cryptocurrency data
"""
try:
logging.info(f"💰 Getting crypto price for: {symbol}")
# CoinGecko free API
# First, try to get the coin ID from symbol
symbol_lower = symbol.lower()
# Map common symbols to CoinGecko IDs
symbol_map = {
"btc": "bitcoin",
"eth": "ethereum",
"usdt": "tether",
"bnb": "binancecoin",
"sol": "solana",
"xrp": "ripple",
"usdc": "usd-coin",
"ada": "cardano",
"doge": "dogecoin",
"trx": "tron",
"dot": "polkadot",
"matic": "matic-network",
"dai": "dai",
"shib": "shiba-inu",
"avax": "avalanche-2"
}
# Use mapped ID or try the symbol directly
coin_id = symbol_map.get(symbol_lower, symbol_lower)
# Get price data from CoinGecko
url = "https://api.coingecko.com/api/v3/simple/price"
params = {
"ids": coin_id,
"vs_currencies": vs_currency,
"include_market_cap": "true",
"include_24hr_vol": "true",
"include_24hr_change": "true",
"include_last_updated_at": "true"
}
headers = {
"User-Agent": "Mozilla/5.0"
}
response = requests.get(url, params=params, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
if not data or coin_id not in data:
raise ValueError(f"Cryptocurrency not found: {symbol}")
coin_data = data[coin_id]
result = {
"symbol": symbol.upper(),
"coin_id": coin_id,
"currency": vs_currency.upper(),
"current_price": coin_data.get(vs_currency),
"market_cap": coin_data.get(f"{vs_currency}_market_cap"),
"volume_24h": coin_data.get(f"{vs_currency}_24h_vol"),
"price_change_24h_percent": coin_data.get(f"{vs_currency}_24h_change"),
"last_updated": datetime.fromtimestamp(coin_data.get("last_updated_at", 0)).isoformat() if coin_data.get("last_updated_at") else None,
"provider": "CoinGecko"
}
logging.info(f"✅ Crypto price: {result['current_price']} {vs_currency.upper()}")
action_response = ActionResponse(
success=True,
message=result,
metadata={"symbol": symbol, "provider": "CoinGecko", "api_key_required": False}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Crypto price query failed: {str(e)}"
logging.error(f"Crypto error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "crypto_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_location(
query: str,
limit: int = 5,
country_code: str | None = None
) -> Union[str, TextContent]:
"""
Search for locations using Nominatim (OpenStreetMap) API (free, no API key required).
Args:
query: Location query (e.g., "Eiffel Tower", "New York", "coffee shop near me")
limit: Maximum number of results (1-50)
country_code: Optional country code filter (e.g., "us", "gb", "fr")
Returns:
TextContent with location search results
"""
try:
logging.info(f"📍 Searching location: {query}")
# Nominatim API (OpenStreetMap)
url = "https://nominatim.openstreetmap.org/search"
params = {
"q": query,
"format": "json",
"limit": min(limit, 50),
"addressdetails": 1,
"extratags": 1
}
if country_code:
params["countrycodes"] = country_code.lower()
headers = {
"User-Agent": "PerceptionToolsMCP/1.0"
}
response = requests.get(url, params=params, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
if not data:
raise ValueError(f"No locations found for: {query}")
locations = []
for item in data:
address = item.get("address", {})
locations.append({
"display_name": item.get("display_name"),
"latitude": float(item.get("lat")),
"longitude": float(item.get("lon")),
"type": item.get("type"),
"category": item.get("class"),
"address": {
"country": address.get("country"),
"country_code": address.get("country_code"),
"state": address.get("state"),
"city": address.get("city") or address.get("town") or address.get("village"),
"postcode": address.get("postcode"),
"road": address.get("road")
},
"importance": item.get("importance"),
"osm_id": item.get("osm_id"),
"osm_type": item.get("osm_type")
})
logging.info(f"✅ Found {len(locations)} locations")
action_response = ActionResponse(
success=True,
message={
"query": query,
"locations": locations,
"count": len(locations)
},
metadata={"provider": "Nominatim (OpenStreetMap)", "api_key_required": False}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Location search failed: {str(e)}"
logging.error(f"Location search error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "location_search_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_poi(
query: str,
latitude: float,
longitude: float,
radius: int = 1000,
limit: int = 10
) -> Union[str, TextContent]:
"""
Search for Points of Interest (POI) near a location using Overpass API (OpenStreetMap).
Free, no API key required.
Args:
query: Type of POI (e.g., "restaurant", "cafe", "hospital", "atm", "hotel")
latitude: Center latitude
longitude: Center longitude
radius: Search radius in meters (default: 1000)
limit: Maximum number of results (default: 10)
Returns:
TextContent with POI search results
"""
try:
logging.info(f"🔍 Searching POIs: {query} near ({latitude}, {longitude})")
# Overpass API query
# Search for amenities, shops, tourism, etc.
overpass_query = f"""
[out:json][timeout:10];
(
node["amenity"~"{query}",i](around:{radius},{latitude},{longitude});
node["shop"~"{query}",i](around:{radius},{latitude},{longitude});
node["tourism"~"{query}",i](around:{radius},{latitude},{longitude});
node["name"~"{query}",i](around:{radius},{latitude},{longitude});
);
out body {limit};
"""
url = "https://overpass-api.de/api/interpreter"
response = requests.post(url, data={"data": overpass_query}, timeout=30)
response.raise_for_status()
data = response.json()
elements = data.get("elements", [])
if not elements:
raise ValueError(f"No POIs found for '{query}' near the specified location")
pois = []
for element in elements[:limit]:
tags = element.get("tags", {})
pois.append({
"name": tags.get("name", "Unnamed"),
"type": tags.get("amenity") or tags.get("shop") or tags.get("tourism") or "unknown",
"latitude": element.get("lat"),
"longitude": element.get("lon"),
"address": tags.get("addr:street"),
"city": tags.get("addr:city"),
"postcode": tags.get("addr:postcode"),
"phone": tags.get("phone"),
"website": tags.get("website"),
"opening_hours": tags.get("opening_hours"),
"cuisine": tags.get("cuisine"),
"osm_id": element.get("id"),
"osm_type": element.get("type")
})
logging.info(f"✅ Found {len(pois)} POIs")
action_response = ActionResponse(
success=True,
message={
"query": query,
"center": {"latitude": latitude, "longitude": longitude},
"radius_meters": radius,
"pois": pois,
"count": len(pois)
},
metadata={"provider": "Overpass API (OpenStreetMap)", "api_key_required": False}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"POI search failed: {str(e)}"
logging.error(f"POI search error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "poi_search_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,431 @@
"""
Search tools: knowledge base, web search, and file download.
"""
import json
import logging
import os
import time
import traceback
from pathlib import Path
from typing import Union
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from mcp.types import TextContent
from pydantic import BaseModel, Field
from base import ActionResponse, is_url, download_file_from_url
load_dotenv()
class SearchResult(BaseModel):
"""Individual search result with structured data."""
id: str
title: str
url: str
snippet: str
source: str
class SearchMetadata(BaseModel):
"""Metadata for search operations."""
query: str
search_engine: str
total_results: int
search_time: float | None = None
language: str = "en"
country: str = "us"
async def search_web(
query: str,
num_results: int = 5,
region: str = "wt-wt"
) -> Union[str, TextContent]:
"""
Search the web using DuckDuckGo (free, no API key required).
Args:
query: The search query string
num_results: Number of results to return (1-10)
region: Region code (e.g., 'us-en', 'uk-en', 'wt-wt' for worldwide)
Returns:
TextContent with search results
"""
try:
if not query or not query.strip():
raise ValueError("Search query cannot be empty")
if num_results <= 0:
metadata = SearchMetadata(
query=query,
search_engine="none",
total_results=0,
search_time=0.0,
language="en",
country=region,
)
action_response = ActionResponse(
success=True,
message={
"query": query,
"results": [],
"count": 0,
},
metadata=metadata.model_dump(),
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump()),
)
validated_num_results = max(1, min(num_results, 10))
logging.info(f"🔍 Searching for: '{query}'")
start_time = time.time()
# Use DuckDuckGo HTML version for scraping
url = "https://html.duckduckgo.com/html/"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
data = {
"q": query.strip(),
"kl": region
}
search_results = []
search_engine = "duckduckgo"
provider_errors = []
try:
response = requests.post(url, data=data, headers=headers, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
result_divs = soup.find_all('div', class_='result')
for i, result_div in enumerate(result_divs[:validated_num_results]):
try:
title_tag = result_div.find('a', class_='result__a')
if not title_tag:
continue
snippet_tag = result_div.find('a', class_='result__snippet')
search_results.append(SearchResult(
id=f"ddg-{i}",
title=title_tag.get_text(strip=True),
url=title_tag.get('href', ''),
snippet=(snippet_tag.get_text(strip=True)
if snippet_tag else ""),
source="duckduckgo",
))
except Exception as exc:
logging.warning("Error parsing DuckDuckGo result %s: %s", i, exc)
except Exception as exc:
provider_errors.append(f"duckduckgo-html:{type(exc).__name__}")
logging.warning("DuckDuckGo HTML search failed: %s", exc)
# DuckDuckGo occasionally serves an HTML variant without ``div.result``
# while still returning HTTP 200. Fall back to its public Lite result
# page so an empty parser match cannot masquerade as a successful
# current-web search.
if not search_results:
try:
lite_response = requests.post(
"https://lite.duckduckgo.com/lite/",
data=data,
headers=headers,
timeout=15,
)
lite_response.raise_for_status()
lite_soup = BeautifulSoup(lite_response.text, "html.parser")
for i, link in enumerate(
lite_soup.select("a.result-link")[:validated_num_results]
):
snippet_tag = link.find_next(class_="result-snippet")
search_results.append(SearchResult(
id=f"ddg-lite-{i}",
title=link.get_text(" ", strip=True),
url=link.get("href", ""),
snippet=(snippet_tag.get_text(" ", strip=True)
if snippet_tag else ""),
source="duckduckgo-lite",
))
if search_results:
search_engine = "duckduckgo-lite"
except Exception as exc:
provider_errors.append(f"duckduckgo-lite:{type(exc).__name__}")
logging.warning("DuckDuckGo Lite search failed: %s", exc)
if not search_results:
serper_key = os.getenv("SERPER_API_KEY")
tavily_key = os.getenv("TAVILY_API_KEY")
if serper_key:
try:
serper = requests.post(
"https://google.serper.dev/search",
headers={"X-API-KEY": serper_key,
"Content-Type": "application/json"},
json={"q": query.strip(), "num": validated_num_results},
timeout=20,
)
serper.raise_for_status()
for i, row in enumerate(
serper.json().get("organic", [])[:validated_num_results]
):
search_results.append(SearchResult(
id=f"serper-{i}", title=row.get("title", ""),
url=row.get("link", ""), snippet=row.get("snippet", ""),
source="serper-google",
))
if search_results:
search_engine = "serper-google"
except Exception as exc:
provider_errors.append(f"serper:{type(exc).__name__}")
logging.warning("Serper search failed; trying next provider: %s", exc)
if not search_results and tavily_key:
try:
tavily = requests.post(
"https://api.tavily.com/search",
json={"api_key": tavily_key, "query": query.strip(),
"max_results": validated_num_results,
"search_depth": "basic", "include_answer": False},
timeout=20,
)
tavily.raise_for_status()
for i, row in enumerate(
tavily.json().get("results", [])[:validated_num_results]
):
search_results.append(SearchResult(
id=f"tavily-{i}", title=row.get("title", ""),
url=row.get("url", ""), snippet=row.get("content", ""),
source="tavily",
))
if search_results:
search_engine = "tavily"
except Exception as exc:
provider_errors.append(f"tavily:{type(exc).__name__}")
logging.warning("Tavily search failed: %s", exc)
if not search_results:
raise LookupError(
"No configured live search provider returned results; attempts="
+ ",".join(provider_errors)
)
search_time = time.time() - start_time
metadata = SearchMetadata(
query=query,
search_engine=search_engine,
total_results=len(search_results),
search_time=search_time,
language="en",
country=region
)
formatted_content = {
"query": query,
"results": [result.model_dump() for result in search_results],
"count": len(search_results)
}
logging.info(f"✅ Found {len(search_results)} results in {search_time:.2f}s")
action_response = ActionResponse(
success=True,
message=formatted_content,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Search operation failed: {str(e)}"
logging.error(f"Search error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "search_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def download_file(
url: str,
output_path: str,
overwrite: bool = False,
timeout: int = 180
) -> Union[str, TextContent]:
"""
Download a file from a URL.
Args:
url: URL to download from
output_path: Local path to save the file
overwrite: Whether to overwrite existing files
timeout: Download timeout in seconds
Returns:
TextContent with download result
"""
try:
if not url.startswith(("http://", "https://")):
raise ValueError("Only HTTP/HTTPS URLs are supported")
output_file = Path(output_path).expanduser().resolve()
if output_file.exists() and not overwrite:
raise ValueError(f"File already exists: {output_file}. Use overwrite=True to replace.")
output_file.parent.mkdir(parents=True, exist_ok=True)
logging.info(f"📥 Downloading from: {url}")
start_time = time.time()
temp_path, content = download_file_from_url(url, timeout=timeout)
# Move to final destination
with open(output_file, 'wb') as f:
f.write(content)
# Clean up temp file
Path(temp_path).unlink(missing_ok=True)
duration = time.time() - start_time
file_size = len(content)
logging.info(f"✅ Downloaded {file_size / 1024:.2f} KB in {duration:.2f}s")
action_response = ActionResponse(
success=True,
message=f"Successfully downloaded file to {output_file}",
metadata={
"url": url,
"output_path": str(output_file),
"file_size_bytes": file_size,
"duration_seconds": duration
}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Download failed: {str(e)}"
logging.error(f"Download error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "download_error", "url": url}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def search_knowledge_base(
query: str,
knowledge_base_path: str,
top_k: int = 5
) -> Union[str, TextContent]:
"""
Search a local knowledge base using simple text matching.
Args:
query: Search query
knowledge_base_path: Path to knowledge base directory
top_k: Number of top results to return
Returns:
TextContent with search results
"""
try:
kb_path = Path(knowledge_base_path).expanduser().resolve()
if not kb_path.exists():
raise FileNotFoundError(f"Knowledge base not found: {kb_path}")
if not kb_path.is_dir():
raise ValueError(f"Knowledge base path must be a directory: {kb_path}")
logging.info(f"🔍 Searching knowledge base: {kb_path}")
# Simple file search - find files containing the query
results = []
query_lower = query.lower()
for file_path in kb_path.rglob("*"):
if file_path.is_file() and file_path.suffix in [".txt", ".md", ".json"]:
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
if query_lower in content.lower():
# Get snippet around first occurrence
idx = content.lower().index(query_lower)
start = max(0, idx - 100)
end = min(len(content), idx + 200)
snippet = content[start:end].strip()
results.append({
"file": str(file_path.relative_to(kb_path)),
"snippet": snippet,
"relevance": content.lower().count(query_lower)
})
except Exception as e:
logging.warning(f"Error reading {file_path}: {e}")
continue
# Sort by relevance and limit
results.sort(key=lambda x: x["relevance"], reverse=True)
results = results[:max(0, top_k)]
logging.info(f"✅ Found {len(results)} results")
action_response = ActionResponse(
success=True,
message={
"query": query,
"results": results,
"total_found": len(results)
},
metadata={
"knowledge_base": str(kb_path),
"top_k": top_k
}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Knowledge base search failed: {str(e)}"
logging.error(f"KB search error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "kb_search_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,20 @@
"""Regression: extract_csv_content must honor max_rows in data, not hard-cap at 100."""
import json
from pathlib import Path
import pandas as pd
import pytest
from document_processing_tools import extract_csv_content
@pytest.mark.asyncio
async def test_csv_data_honors_max_rows(tmp_path: Path):
path = tmp_path / "t.csv"
pd.DataFrame({"id": range(250)}).to_csv(path, index=False)
r = await extract_csv_content(str(path), max_rows=1000)
payload = json.loads(r.text)
msg = payload["message"]
assert msg["rows"] == 250
assert len(msg["data"]) == 250
assert msg["truncated"] is False
@@ -0,0 +1,59 @@
"""Regression: grep_search(max_results=0) must return zero hits, not one."""
import json
import sys
import types
from pathlib import Path
import pytest
def _stub():
for name in ["dotenv", "requests", "mcp", "mcp.types", "mcp.server"]:
sys.modules.setdefault(name, types.ModuleType(name))
sys.modules["dotenv"].load_dotenv = lambda *a, **k: None
class TextContent:
def __init__(self, type=None, text=None):
self.type = type
self.text = text
sys.modules["mcp.types"].TextContent = TextContent
class MCPServer:
def __init__(self, *a, **k):
pass
def tool(self, *a, **k):
def deco(fn):
return fn
return deco
sys.modules["mcp.server"].MCPServer = MCPServer
_stub()
from filesystem_tools import grep_search # noqa: E402
@pytest.mark.asyncio
async def test_max_results_zero_returns_no_matches(tmp_path: Path):
(tmp_path / "a.py").write_text("hello world\nhello again\n", encoding="utf-8")
r = await grep_search("hello", str(tmp_path), max_results=0)
payload = json.loads(r.text if hasattr(r, "text") else r)
assert payload["success"] is True
msg = payload["message"]
assert msg["results"] == []
assert msg["total_found"] == 0
assert msg["truncated"] is False
@pytest.mark.asyncio
async def test_max_results_one_still_caps(tmp_path: Path):
(tmp_path / "a.py").write_text("hello world\nhello again\n", encoding="utf-8")
r = await grep_search("hello", str(tmp_path), max_results=1)
payload = json.loads(r.text if hasattr(r, "text") else r)
msg = payload["message"]
assert msg["total_found"] == 1
assert len(msg["results"]) == 1
assert msg["truncated"] is True
@@ -0,0 +1,45 @@
"""Regression: negative max_length must not drop the last character."""
import asyncio
import json
import sys
import types
from pathlib import Path
import pytest
def _stub():
for name in ["dotenv", "requests", "mcp", "mcp.types", "mcp.server"]:
sys.modules.setdefault(name, types.ModuleType(name))
sys.modules["dotenv"].load_dotenv = lambda *a, **k: None
class TextContent:
def __init__(self, type=None, text=None):
self.type = type
self.text = text
sys.modules["mcp.types"].TextContent = TextContent
class MCPServer:
def __init__(self, *a, **k): pass
def tool(self, *a, **k):
def deco(fn): return fn
return deco
sys.modules["mcp.server"].MCPServer = MCPServer
_stub()
from filesystem_tools import read_file # noqa: E402
@pytest.mark.asyncio
async def test_negative_max_length_keeps_full_content(tmp_path: Path):
path = tmp_path / "a.txt"
path.write_text("hello world", encoding="utf-8")
r = await read_file(str(path), max_length=-1)
payload = json.loads(r.text if hasattr(r, "text") else r)
msg = payload.get("message", payload)
if isinstance(msg, dict) and "content" in msg:
content = msg["content"]
elif isinstance(msg, str):
content = msg
else:
content = str(msg)
assert "hello world" in content
@@ -0,0 +1,66 @@
"""Regression test for search_web num_results=0 handling.
Proves contract: Requesting zero search results short-circuits external search
providers and returns success with empty result list and count 0.
Locks out bug where max(1, min(num_results, 10)) clamped num_results=0 to 1.
"""
import json
import sys
import types
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
def _stub():
for name in ["dotenv", "mcp", "mcp.types", "mcp.server", "mcp.server.fastmcp"]:
sys.modules.setdefault(name, types.ModuleType(name))
sys.modules["dotenv"].load_dotenv = lambda *a, **k: None
class TextContent:
def __init__(self, type=None, text=None):
self.type = type
self.text = text
sys.modules["mcp.types"].TextContent = TextContent
class FastMCP:
def __init__(self, *a, **k):
pass
def tool(self, *a, **k):
def deco(fn):
return fn
return deco
sys.modules["mcp.server.fastmcp"].FastMCP = FastMCP
_stub()
from search_tools import search_web # noqa: E402
@pytest.mark.asyncio
async def test_search_web_num_results_zero_returns_no_results():
result = await search_web("Python", num_results=0)
payload = json.loads(result.text if hasattr(result, "text") else result)
assert payload["success"] is True
message = payload["message"]
assert message["results"] == []
assert message["count"] == 0
assert payload["metadata"]["total_results"] == 0
@pytest.mark.asyncio
async def test_search_web_num_results_negative_returns_no_results():
result = await search_web("Python", num_results=-5)
payload = json.loads(result.text if hasattr(result, "text") else result)
assert payload["success"] is True
message = payload["message"]
assert message["results"] == []
assert message["count"] == 0
assert payload["metadata"]["total_results"] == 0
@@ -0,0 +1,83 @@
"""
Enhanced Wayback Machine tools.
"""
import json
import logging
import traceback
from typing import Union
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from mcp.types import TextContent
from waybackpy import WaybackMachineCDXServerAPI
from base import ActionResponse
load_dotenv()
async def get_archived_content(
url: str,
timestamp: str
) -> Union[str, TextContent]:
"""
Get content from archived webpage.
Args:
url: URL to retrieve
timestamp: Wayback timestamp (YYYYMMDDhhmmss)
Returns:
TextContent with archived content
"""
try:
logging.info(f"🕰️ Getting archived content: {url} at {timestamp}")
# Query for closest snapshot
cdx_api = WaybackMachineCDXServerAPI(url)
snapshot = cdx_api.near(wayback_machine_timestamp=timestamp)
if not snapshot:
raise ValueError("No archived version found")
# Fetch content
response = requests.get(snapshot.archive_url, timeout=30)
response.raise_for_status()
# Extract text
soup = BeautifulSoup(response.content, 'html.parser')
text = soup.get_text(separator=" ", strip=True)
result = {
"url": url,
"timestamp": timestamp,
"actual_timestamp": snapshot.timestamp,
"archive_url": snapshot.archive_url,
"content": text[:10000], # Limit to 10k chars
"content_length": len(text)
}
action_response = ActionResponse(
success=True,
message=result,
metadata={"url": url}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Failed: {str(e)}",
metadata={"error_type": "wayback_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,269 @@
"""
Enhanced Wikipedia tools with full article access.
Based on AWorld wiki-server complete implementation.
"""
import json
import logging
import traceback
import calendar
from datetime import datetime
from typing import Union
import requests
import wikipedia
from dotenv import load_dotenv
from mcp.types import TextContent
from base import ActionResponse
load_dotenv()
async def get_article_content(
title: str,
language: str = "en"
) -> Union[str, TextContent]:
"""
Get full Wikipedia article content.
Args:
title: Article title
language: Language code
Returns:
TextContent with full article
"""
try:
wikipedia.set_lang(language)
logging.info(f"📚 Getting full article: {title}")
page = wikipedia.page(title, auto_suggest=True)
result = {
"title": page.title,
"url": page.url,
"content": page.content,
"summary": page.summary,
"categories": page.categories[:20] if page.categories else [],
"links": page.links[:50] if page.links else [],
"images": page.images[:10] if page.images else []
}
logging.info(f"✅ Retrieved article: {len(page.content)} chars")
action_response = ActionResponse(
success=True,
message=result,
metadata={"language": language, "title": page.title}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to get article: {str(e)}"
logging.error(f"Wiki error: {traceback.format_exc()}")
action_response = ActionResponse(
success=False,
message=error_msg,
metadata={"error_type": "wiki_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_article_categories(
title: str,
language: str = "en"
) -> Union[str, TextContent]:
"""
Get categories for Wikipedia article.
Args:
title: Article title
language: Language code
Returns:
TextContent with categories
"""
try:
wikipedia.set_lang(language)
page = wikipedia.page(title, auto_suggest=True)
result = {
"title": page.title,
"categories": page.categories,
"count": len(page.categories)
}
action_response = ActionResponse(
success=True,
message=result,
metadata={"language": language}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Failed: {str(e)}",
metadata={"error_type": "wiki_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_article_links(
title: str,
language: str = "en"
) -> Union[str, TextContent]:
"""
Get links from Wikipedia article.
Args:
title: Article title
language: Language code
Returns:
TextContent with links
"""
try:
wikipedia.set_lang(language)
page = wikipedia.page(title, auto_suggest=True)
result = {
"title": page.title,
"links": page.links[:100], # Limit to 100
"total_links": len(page.links),
"count": min(100, len(page.links))
}
action_response = ActionResponse(
success=True,
message=result,
metadata={"language": language}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Failed: {str(e)}",
metadata={"error_type": "wiki_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_article_history(
title: str,
date: str,
language: str = "en"
) -> Union[str, TextContent]:
"""
Get historical version of Wikipedia article.
Args:
title: Article title
date: Target date (YYYY/MM/DD)
language: Language code
Returns:
TextContent with historical content
"""
try:
logging.info(f"📚 Getting historical version: {title} at {date}")
# Parse date (YYYY/MM or YYYY/MM/DD)
if not isinstance(date, str) or "/" not in date:
raise ValueError("date must be YYYY/MM/DD or YYYY/MM")
date_parts = date.split("/")
if len(date_parts) < 2:
raise ValueError("date must be YYYY/MM/DD or YYYY/MM")
year = int(date_parts[0])
month = int(date_parts[1])
day = int(date_parts[2]) if len(date_parts) > 2 else calendar.monthrange(year, month)[1]
target_date = datetime(year, month, day)
# Get page revisions via Wikipedia API
params = {
"action": "query",
"prop": "revisions",
"titles": title,
"rvprop": "ids|timestamp|user|comment|content",
"rvlimit": 1,
"rvdir": "older",
"rvstart": target_date.isoformat(),
"format": "json"
}
api_url = f"https://{language}.wikipedia.org/w/api.php"
response = requests.get(api_url, params=params, timeout=10)
data = response.json()
page = next(iter(data["query"]["pages"].values()))
if "revisions" in page:
revision = page["revisions"][0]
actual_date = datetime.fromisoformat(revision["timestamp"].replace("Z", "+00:00"))
result = {
"title": title,
"requested_date": date,
"actual_date": actual_date.strftime("%Y/%m/%d"),
"content": revision["*"],
"editor": revision["user"],
"comment": revision.get("comment", "")
}
action_response = ActionResponse(
success=True,
message=result,
metadata={"language": language}
)
else:
action_response = ActionResponse(
success=False,
message="No historical version found",
metadata={"error_type": "not_found"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
action_response = ActionResponse(
success=False,
message=f"Failed: {str(e)}",
metadata={"error_type": "wiki_error"}
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
@@ -0,0 +1,437 @@
"""
Yahoo Finance comprehensive tools.
Based on AWorld MCP server implementation.
Provides stock quotes, historical data, company info, and financial statements.
"""
import json
import logging
import time
import traceback
from datetime import datetime
from typing import Union, Literal
import yfinance as yf
from dotenv import load_dotenv
from mcp.types import TextContent
from pydantic import BaseModel, Field
from base import ActionResponse
load_dotenv()
class YFinanceMetadata(BaseModel):
"""Metadata for Yahoo Finance operation results."""
symbol: str
operation: str
execution_time: float | None = None
data_points: int | None = None
error_type: str | None = None
timestamp: str | None = None
async def get_stock_quote(
symbol: str
) -> Union[str, TextContent]:
"""
Get current stock quote information.
Args:
symbol: Stock ticker symbol (e.g., AAPL, MSFT)
Returns:
TextContent with quote data
"""
try:
start_time = time.time()
logging.info(f"📈 Fetching stock quote for: {symbol}")
ticker = yf.Ticker(symbol)
info = ticker.info
if not info or (info.get("regularMarketPrice") is None and info.get("currentPrice") is None):
# Try to get basic history to validate symbol
hist = ticker.history(period="1d")
if hist.empty:
raise ValueError(f"No data found for symbol: {symbol}")
raise ValueError(f"Could not retrieve detailed quote for symbol: {symbol}")
# Extract key quote information
quote_data = {
"symbol": symbol.upper(),
"company_name": info.get("shortName", info.get("longName")),
"current_price": info.get("regularMarketPrice", info.get("currentPrice")),
"previous_close": info.get("previousClose"),
"open": info.get("regularMarketOpen", info.get("open")),
"day_high": info.get("regularMarketDayHigh", info.get("dayHigh")),
"day_low": info.get("regularMarketDayLow", info.get("dayLow")),
"volume": info.get("regularMarketVolume", info.get("volume")),
"average_volume": info.get("averageVolume"),
"market_cap": info.get("marketCap"),
"fifty_two_week_high": info.get("fiftyTwoWeekHigh"),
"fifty_two_week_low": info.get("fiftyTwoWeekLow"),
"currency": info.get("currency"),
"exchange": info.get("exchange")
}
# Filter out None values
quote_data = {k: v for k, v in quote_data.items() if v is not None}
# Calculate change
if quote_data.get("current_price") and quote_data.get("previous_close"):
change = quote_data["current_price"] - quote_data["previous_close"]
change_pct = (change / quote_data["previous_close"]) * 100
quote_data["change"] = round(change, 2)
quote_data["change_percent"] = round(change_pct, 2)
execution_time = time.time() - start_time
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_stock_quote",
execution_time=execution_time,
data_points=len(quote_data),
timestamp=datetime.now().isoformat()
)
logging.info(f"✅ Stock quote: ${quote_data.get('current_price')}")
action_response = ActionResponse(
success=True,
message=quote_data,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to fetch stock quote: {str(e)}"
logging.error(f"Stock quote error: {traceback.format_exc()}")
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_stock_quote",
error_type=type(e).__name__,
timestamp=datetime.now().isoformat()
)
action_response = ActionResponse(
success=False,
message=error_msg,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_historical_data(
symbol: str,
start: str,
end: str,
interval: str = "1d",
max_rows_preview: int = 10
) -> Union[str, TextContent]:
"""
Retrieve historical stock data.
Args:
symbol: Stock ticker symbol
start: Start date (YYYY-MM-DD)
end: End date (YYYY-MM-DD)
interval: Data interval (1d, 1wk, 1mo, etc.)
max_rows_preview: Maximum rows to show in preview (0 for all)
Returns:
TextContent with historical data
"""
try:
start_time = time.time()
logging.info(f"📈 Fetching historical data for: {symbol}")
ticker = yf.Ticker(symbol)
hist_df = ticker.history(start=start, end=end, interval=interval)
if hist_df.empty:
raise ValueError(f"No historical data found for {symbol}")
# Convert DataFrame to list of dictionaries
hist_df.reset_index(inplace=True)
# Ensure date columns are strings
if "Date" in hist_df.columns:
hist_df["Date"] = hist_df["Date"].astype(str)
if "Datetime" in hist_df.columns:
hist_df["Datetime"] = hist_df["Datetime"].astype(str)
# Clean column names
hist_df.columns = hist_df.columns.str.replace(" ", "")
historical_data = hist_df.to_dict(orient="records")
execution_time = time.time() - start_time
# Prepare result with preview
result = {
"symbol": symbol.upper(),
"start_date": start,
"end_date": end,
"interval": interval,
"total_records": len(historical_data),
"data": historical_data if max_rows_preview == 0 else historical_data[:max_rows_preview]
}
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_historical_data",
execution_time=execution_time,
data_points=len(historical_data),
timestamp=datetime.now().isoformat()
)
logging.info(f"✅ Retrieved {len(historical_data)} historical records")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to fetch historical data: {str(e)}"
logging.error(f"Historical data error: {traceback.format_exc()}")
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_historical_data",
error_type=type(e).__name__,
timestamp=datetime.now().isoformat()
)
action_response = ActionResponse(
success=False,
message=error_msg,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_company_info(
symbol: str
) -> Union[str, TextContent]:
"""
Get company information and business details.
Args:
symbol: Stock ticker symbol
Returns:
TextContent with company information
"""
try:
start_time = time.time()
logging.info(f"🏢 Fetching company info for: {symbol}")
ticker = yf.Ticker(symbol)
info = ticker.info
if not info or not info.get("symbol"):
raise ValueError(f"No company information found for symbol: {symbol}")
# Extract key company information
company_data = {
"symbol": info.get("symbol"),
"short_name": info.get("shortName"),
"long_name": info.get("longName"),
"sector": info.get("sector"),
"industry": info.get("industry"),
"full_time_employees": info.get("fullTimeEmployees"),
"business_summary": info.get("longBusinessSummary"),
"city": info.get("city"),
"state": info.get("state"),
"country": info.get("country"),
"website": info.get("website"),
"exchange": info.get("exchange"),
"currency": info.get("currency"),
"market_cap": info.get("marketCap"),
"pe_ratio": info.get("trailingPE"),
"forward_pe": info.get("forwardPE"),
"dividend_yield": info.get("dividendYield"),
"beta": info.get("beta")
}
# Filter out None values
company_data = {k: v for k, v in company_data.items() if v is not None}
execution_time = time.time() - start_time
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_company_info",
execution_time=execution_time,
data_points=len(company_data),
timestamp=datetime.now().isoformat()
)
logging.info(f"✅ Retrieved company info: {company_data.get('long_name', company_data.get('short_name'))}")
action_response = ActionResponse(
success=True,
message=company_data,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to fetch company info: {str(e)}"
logging.error(f"Company info error: {traceback.format_exc()}")
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_company_info",
error_type=type(e).__name__,
timestamp=datetime.now().isoformat()
)
action_response = ActionResponse(
success=False,
message=error_msg,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
async def get_financial_statements(
symbol: str,
statement_type: Literal["income_statement", "balance_sheet", "cash_flow"],
period_type: Literal["annual", "quarterly"] = "annual",
max_columns_preview: int = 4
) -> Union[str, TextContent]:
"""
Get financial statements for a company.
Args:
symbol: Stock ticker symbol
statement_type: Type of statement (income_statement, balance_sheet, cash_flow)
period_type: Period type (annual or quarterly)
max_columns_preview: Maximum periods to show (0 for all)
Returns:
TextContent with financial statement data
"""
try:
start_time = time.time()
logging.info(f"📋 Fetching {statement_type} for: {symbol}")
ticker = yf.Ticker(symbol)
statement_df = None
# Get appropriate statement
if statement_type == "income_statement":
statement_df = ticker.income_stmt if period_type == "annual" else ticker.quarterly_income_stmt
elif statement_type == "balance_sheet":
statement_df = ticker.balance_sheet if period_type == "annual" else ticker.quarterly_balance_sheet
elif statement_type == "cash_flow":
statement_df = ticker.cashflow if period_type == "annual" else ticker.quarterly_cashflow
else:
raise ValueError(f"Invalid statement_type: {statement_type}")
if statement_df is None or statement_df.empty:
raise ValueError(f"No {period_type} {statement_type} data found for symbol {symbol}")
# Process DataFrame
statement_df.reset_index(inplace=True)
statement_df.rename(columns={"index": "Item"}, inplace=True)
# Convert date columns to strings
for col in statement_df.columns:
if col != "Item":
try:
if hasattr(col, "strftime"):
new_col_name = col.strftime("%Y-%m-%d")
statement_df.rename(columns={col: new_col_name}, inplace=True)
except Exception:
pass
# Limit columns if needed
if max_columns_preview > 0 and len(statement_df.columns) > (max_columns_preview + 1):
columns_to_keep = ["Item"] + list(statement_df.columns[1:max_columns_preview + 1])
statement_df = statement_df[columns_to_keep]
statement_data = statement_df.to_dict(orient="records")
execution_time = time.time() - start_time
result = {
"symbol": symbol.upper(),
"statement_type": statement_type,
"period_type": period_type,
"total_line_items": len(statement_data),
"periods": len(statement_df.columns) - 1,
"data": statement_data
}
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_financial_statements",
execution_time=execution_time,
data_points=len(statement_data),
timestamp=datetime.now().isoformat()
)
logging.info(f"✅ Retrieved {statement_type}: {len(statement_data)} items")
action_response = ActionResponse(
success=True,
message=result,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)
except Exception as e:
error_msg = f"Failed to fetch financial statements: {str(e)}"
logging.error(f"Financial statements error: {traceback.format_exc()}")
metadata = YFinanceMetadata(
symbol=symbol.upper(),
operation="get_financial_statements",
error_type=type(e).__name__,
timestamp=datetime.now().isoformat()
)
action_response = ActionResponse(
success=False,
message=error_msg,
metadata=metadata.model_dump()
)
return TextContent(
type="text",
text=json.dumps(action_response.model_dump())
)