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
+11
View File
@@ -0,0 +1,11 @@
# Local credentials and interpreter caches
.env
__pycache__/
*.py[cod]
.pytest_cache/
# Experiment evidence is intentionally durable, including tiny media fixtures
# that are ignored by the repository-wide extension rules.
!experiment_protocol.json
!validation/
!validation/**
+328
View File
@@ -0,0 +1,328 @@
# Architecture Overview
## System Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ MCP Client (e.g., Claude) │
└───────────────────────────────┬─────────────────────────────────┘
│ stdio
┌───────────────────────────────▼─────────────────────────────────┐
│ main.py (MCPServer) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Tool Registration (@mcp.tool) │ │
│ └──────────────────────────────────────────────────────────┘ │
└───┬──────┬──────────┬──────────┬────────────┬──────────────┬───┘
│ │ │ │ │ │
┌───▼──┐ ┌─▼────┐ ┌──▼──┐ ┌─────▼────┐ ┌────▼─────┐ ┌──────▼────┐
│Search│ │Multi │ │File │ │ Public │ │ Private │ │ Base │
│Tools │ │modal │ │Sys │ │ Data │ │ Data │ │ Utilities │
│ (3) │ │Tools │ │Tools│ │Tools (6) │ │Tools (2) │ │ │
│ │ │ (4) │ │ (3) │ │ │ │ │ │ │
└───┬──┘ └─┬────┘ └──┬──┘ └─────┬────┘ └────┬─────┘ └──────┬────┘
│ │ │ │ │ │
│ │ │ │ │ │
└──────┴─────────┴──────────┴────────────┴──────────────┘
┌───────────▼───────────┐
│ ActionResponse Model │
│ (Standardized Output) │
└───────────────────────┘
```
## Module Dependencies
```
main.py
├── search_tools.py
│ ├── base.py (ActionResponse, is_url, download_file_from_url)
│ ├── requests
│ └── mcp.types (TextContent)
├── multimodal_tools.py
│ ├── base.py (ActionResponse, validate_file_path, download_file_from_url)
│ ├── beautifulsoup4
│ ├── PyPDF2
│ ├── python-docx
│ ├── python-pptx
│ ├── Pillow
│ └── opencv-python
├── filesystem_tools.py
│ ├── base.py (ActionResponse, validate_file_path)
│ └── re (standard library)
├── public_data_tools.py
│ ├── base.py (ActionResponse)
│ ├── requests
│ ├── wikipedia
│ └── arxiv
├── private_data_tools.py
│ ├── base.py (ActionResponse)
│ ├── google-api-python-client (optional)
│ └── notion-client (optional)
└── base.py
├── pydantic (BaseModel, Field)
└── requests
```
## Data Flow
### Request Flow
```
1. MCP Client sends tool request via stdio
2. MCPServer receives and validates the request
3. Appropriate tool function is called
4. Tool function processes request
5. External API calls (if needed)
6. Data processing and transformation
7. ActionResponse object created
8. Wrapped in TextContent
9. JSON serialized and returned via stdio
10. MCP Client receives and processes response
```
### Error Flow
```
1. Exception occurs in tool function
2. Exception caught in try-except block
3. Error logged with traceback
4. ActionResponse created with success=False
5. Error details in message and metadata
6. Returned to client (no crashes)
```
## Component Responsibilities
### main.py
- **Role**: MCP server initialization and tool registration
- **Responsibilities**:
- Construct the MCPServer
- Register all tool functions with decorators
- Provide server-level instructions
- Run stdio transport loop
- **Dependencies**: All tool modules
### base.py
- **Role**: Shared utilities and models
- **Responsibilities**:
- Define ActionResponse model
- Define DocumentMetadata model
- Provide URL validation (is_url)
- Provide file validation (validate_file_path)
- Provide file download utility (download_file_from_url)
- **Dependencies**: pydantic, requests
### search_tools.py
- **Role**: Search and retrieval operations
- **Tools**:
- web_search: Google Custom Search API
- download_file: HTTP/HTTPS file downloads
- search_knowledge_base: Local file search
- **External APIs**: Google Custom Search
- **Dependencies**: requests, base
### multimodal_tools.py
- **Role**: Content extraction from various media
- **Tools**:
- read_webpage: HTML parsing
- read_document: Document extraction (PDF/DOCX/PPTX)
- parse_image: Image metadata and analysis
- parse_video: Video metadata extraction
- **File Formats**: HTML, PDF, DOCX, PPTX, JPG, PNG, MP4, etc.
- **Dependencies**: beautifulsoup4, PyPDF2, python-docx, python-pptx, Pillow, opencv-python, base
### filesystem_tools.py
- **Role**: File system operations
- **Tools**:
- read_file: Read file contents
- grep_search: Pattern search in files
- summarize_text: Text summarization
- **Dependencies**: re (stdlib), base
### public_data_tools.py
- **Role**: Public API integrations
- **Tools**:
- get_weather: OpenWeather API
- get_stock_price: Yahoo Finance
- convert_currency: Exchange rate API
- search_wikipedia: Wikipedia API
- search_arxiv: ArXiv API
- search_wayback: Wayback Machine API
- **External APIs**: 6 different public APIs
- **Dependencies**: requests, wikipedia, arxiv, base
### private_data_tools.py
- **Role**: Private data source integrations
- **Tools**:
- get_calendar_events: Google Calendar OAuth2
- search_notion: Notion API
- **External APIs**: Google Calendar, Notion
- **Dependencies**: google-api-python-client (optional), notion-client (optional), base
## Configuration Management
```
Environment Variables (.env)
├── GOOGLE_API_KEY (required for web search)
├── GOOGLE_CSE_ID (required for web search)
├── OPENWEATHER_API_KEY (required for weather)
├── NOTION_API_KEY (optional for Notion)
└── Google OAuth2 Credentials (optional for Calendar)
```
## Error Handling Strategy
### Levels of Error Handling
1. **Input Validation**
- Parameter validation
- File existence checks
- URL format validation
2. **External API Errors**
- Network timeouts
- HTTP errors
- API quota limits
- Authentication failures
3. **Processing Errors**
- File parsing errors
- Encoding errors
- Memory limits
4. **Graceful Degradation**
- Return partial results when possible
- Clear error messages
- Suggest remediation steps
### Error Response Format
```json
{
"success": false,
"message": "Human-readable error description",
"metadata": {
"error_type": "category_of_error",
"additional_context": "more details"
}
}
```
## Performance Characteristics
### Timeouts
- Web requests: 10-30 seconds
- File downloads: 180 seconds
- Long operations: 300 seconds
### Limits
- File download size: 100 MB
- Video download size: 500 MB
- Text read limit: 50,000 characters
- Search results: 5-100 items
### Concurrency
- Async/await throughout
- Single-threaded stdio transport
- Non-blocking external API calls
## Security Considerations
### Input Validation
- Path traversal prevention
- URL scheme restrictions (HTTP/HTTPS only)
- File size limits
- Timeout enforcement
### API Security
- API keys via environment variables
- OAuth2 for Google Calendar
- Token-based auth for Notion
- No hardcoded credentials
### Output Sanitization
- JSON encoding for all responses
- Base64 encoding for binary data
- Length limits on returned data
## Extensibility Points
### Adding New Tools
1. Create function in appropriate module
2. Follow async pattern
3. Use ActionResponse format
4. Add error handling
5. Register in main.py
6. Update documentation
### Adding New Categories
1. Create new module in src/
2. Import base utilities
3. Implement tools following patterns
4. Import in main.py
5. Register tools
6. Update documentation
### Adding New Data Sources
1. Add to public_data_tools.py or private_data_tools.py
2. Implement API client
3. Follow error handling patterns
4. Document API requirements
5. Update env.example
## Testing Strategy
### Unit Testing (Future)
- Test each tool function
- Mock external APIs
- Test error conditions
- Validate response format
### Integration Testing
- test_imports.py: Verify all imports
- quickstart.py: Test actual functionality
- Manual testing via MCP client
### Production Monitoring
- Logging throughout
- Error tracking
- Performance metrics (timing)
- API quota monitoring
## Deployment Considerations
### Requirements
- Python 3.10+
- All dependencies in requirements.txt
- Environment variables configured
- Network access for external APIs
### Running in Production
- Use process manager (systemd, supervisor)
- Configure appropriate timeouts
- Monitor logs
- Set up API key rotation
- Implement rate limiting if needed
### Scaling
- Current: Single process, stdio transport
- Future: Could add HTTP transport for multiple clients
- Future: Could implement caching layer
- Future: Could add request queuing
+223
View File
@@ -0,0 +1,223 @@
# Perception Tools - Updates & Changes
## Summary
Updated the Perception Tools MCP Server to use **100% free, open APIs** that require **no API keys**. This makes the tool immediately usable without any setup or registration.
## Changes Made
### 1. **Weather API** - Replaced OpenWeather with Open-Meteo
**Previous**: OpenWeather API (required API key)
**New**: [Open-Meteo](https://open-meteo.com/) (free, no API key required)
**Benefits**:
- No registration or API key needed
- Automatic geocoding of city names
- High-quality weather data from national weather services
- Hourly resolution with up to 16-day forecasts
- 80 years of historical weather data available
**Implementation**:
- `get_weather(location, latitude=None, longitude=None)`
- Automatically geocodes location names to coordinates
- Returns temperature, humidity, wind speed, precipitation, and weather description
---
### 2. **Web Search** - Replaced Google Custom Search with DuckDuckGo
**Previous**: Google Custom Search API (required API key and CSE ID)
**New**: DuckDuckGo HTML search (free, no API key required)
**Benefits**:
- No registration or API key needed
- Privacy-focused search engine
- Clean, ad-free results
- No usage limits
**Implementation**:
- `search_web(query, num_results=5, region="wt-wt")`
- Scrapes DuckDuckGo HTML results
- Returns title, URL, and snippet for each result
---
### 3. **Cryptocurrency Prices** - NEW TOOL ✨
**API**: [CoinGecko](https://www.coingecko.com/) (free, no API key required)
**Features**:
- Real-time cryptocurrency prices
- Support for 15+ major cryptocurrencies (BTC, ETH, SOL, etc.)
- Market cap, 24h volume, and price change data
- Multi-currency support (USD, EUR, GBP, etc.)
**Implementation**:
- `get_crypto_price(symbol, vs_currency="usd")`
- Maps common symbols (btc → bitcoin, eth → ethereum)
- Returns comprehensive price and market data
**Example**:
```python
result = await get_crypto_price("btc", "usd")
# Returns: BTC price, market cap, 24h volume, 24h change
```
---
### 4. **Location Search** - NEW TOOL ✨
**API**: [Nominatim (OpenStreetMap)](https://nominatim.openstreetmap.org/) (free, no API key required)
**Features**:
- Geocode any location worldwide
- Search landmarks, cities, addresses
- Detailed address information
- Country filtering option
**Implementation**:
- `search_location(query, limit=5, country_code=None)`
- Returns latitude, longitude, and detailed address
- Importance ranking for result relevance
**Example**:
```python
result = await search_location("Eiffel Tower")
# Returns: coordinates, full address, location type
```
---
### 5. **Point of Interest (POI) Search** - NEW TOOL ✨
**API**: [Overpass API (OpenStreetMap)](https://overpass-api.de/) (free, no API key required)
**Features**:
- Find restaurants, cafes, hotels, ATMs, hospitals, etc.
- Search within specified radius
- Rich metadata (phone, website, opening hours, cuisine)
- Worldwide coverage from OpenStreetMap data
**Implementation**:
- `search_poi(query, latitude, longitude, radius=1000, limit=10)`
- Searches for amenities, shops, tourism POIs
- Returns name, type, coordinates, and metadata
**Example**:
```python
result = await search_poi("restaurant", 48.8584, 2.2945, radius=500)
# Returns: nearby restaurants with details
```
---
## Existing Free APIs (Already Implemented)
These tools were already using free APIs:
1. **Stock Prices** - Yahoo Finance (free, no API key)
2. **Currency Conversion** - ExchangeRate-API (free, no API key)
3. **Wikipedia** - Wikipedia API (free, no API key)
4. **ArXiv** - ArXiv API (free, no API key)
5. **Wayback Machine** - Internet Archive (free, no API key)
---
## Complete List of Free Tools
### 🔍 Search & Discovery
- ✅ Web Search (DuckDuckGo)
- ✅ Knowledge Base Search
- ✅ Wikipedia Search
- ✅ ArXiv Search
- ✅ Location Search (OpenStreetMap)
- ✅ POI Search (OpenStreetMap)
### 🌐 Public Data
- ✅ Weather (Open-Meteo)
- ✅ Stock Prices (Yahoo Finance)
- ✅ Crypto Prices (CoinGecko)
- ✅ Currency Conversion (ExchangeRate-API)
### 📄 Content Processing
- ✅ Web Page Reader
- ✅ Document Reader (PDF, DOCX, PPTX)
- ✅ File Operations
- ✅ Grep Search
### 🕰️ Historical Data
- ✅ Wayback Machine
---
## Testing
All new and updated tools have been tested and verified:
```bash
# Test original tools
python quickstart.py
# Test new tools
python test_new_tools.py
```
All tests pass successfully with no API keys required!
---
## Documentation Updates
- ✅ Updated README.md with new tool descriptions
- ✅ Updated env.example to reflect free APIs
- ✅ Added comprehensive parameter documentation
- ✅ Highlighted "No API Keys Required" throughout
---
## Benefits of These Changes
1. **Zero Setup** - Works immediately after `pip install -r requirements.txt`
2. **No Cost** - All APIs are free for reasonable use
3. **No Registration** - No need to sign up or manage API keys
4. **Privacy** - DuckDuckGo and OpenStreetMap don't track users
5. **Production Ready** - APIs are stable and well-maintained
6. **Global Coverage** - Weather, maps, and location data worldwide
---
## API Sources & Links
| Tool | API Provider | Documentation |
|------|-------------|---------------|
| Weather | Open-Meteo | https://open-meteo.com/ |
| Web Search | DuckDuckGo | https://duckduckgo.com/ |
| Crypto Prices | CoinGecko | https://www.coingecko.com/en/api |
| Location Search | Nominatim (OSM) | https://nominatim.openstreetmap.org/ |
| POI Search | Overpass API (OSM) | https://overpass-api.de/ |
| Stock Prices | Yahoo Finance | https://finance.yahoo.com/ |
| Currency | ExchangeRate-API | https://www.exchangerate-api.com/ |
| Wikipedia | Wikipedia API | https://www.mediawiki.org/wiki/API |
| ArXiv | ArXiv API | https://arxiv.org/help/api |
---
## Acknowledgments
Special thanks to:
- Open-Meteo for providing free, high-quality weather data
- OpenStreetMap community for maintaining global map data
- CoinGecko for free cryptocurrency market data
- DuckDuckGo for privacy-respecting search
---
## Next Steps
Potential future enhancements:
- Add rate limiting to respect API usage policies
- Implement caching for frequently accessed data
- Add more cryptocurrency exchanges
- Support for reverse geocoding
- Route planning with OpenStreetMap
+64
View File
@@ -0,0 +1,64 @@
# Perception Tools MCP Server Dockerfile
# Supports document processing, web search, and data retrieval
# Uses latest stable versions (as of 2025)
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV TZ=UTC
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
build-essential \
software-properties-common \
ca-certificates \
# For image processing
libgl1-mesa-glx \
libglib2.0-0 \
# For PDF processing
poppler-utils \
tesseract-ocr \
tesseract-ocr-eng \
tesseract-ocr-chi-sim \
&& rm -rf /var/lib/apt/lists/*
# Install Python 3.13 (latest stable)
RUN add-apt-repository ppa:deadsnakes/ppa && \
apt-get update && \
apt-get install -y \
python3.13 \
python3.13-dev \
python3.13-venv \
python3-pip \
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.13 1 && \
update-alternatives --install /usr/bin/python python /usr/bin/python3.13 1 && \
rm -rf /var/lib/apt/lists/*
# Set up working directory
WORKDIR /app
# Copy requirements and install Python packages
COPY requirements.txt .
RUN python3 -m pip install --upgrade pip setuptools wheel && \
python3 -m pip install -r requirements.txt
# Copy application files
COPY . .
# Set environment variables
ENV DATA_DIR=/data
RUN mkdir -p /data && chmod 777 /data
# Create non-root user for security
RUN useradd -m -u 1000 mcpuser && \
chown -R mcpuser:mcpuser /app /data
# Switch to non-root user
USER mcpuser
# Run the MCP server
CMD ["python3", "src/main.py"]
+239
View File
@@ -0,0 +1,239 @@
# Perception Tools MCP Server - Complete Index
## Quick Navigation
### Getting Started
- [README.md](README.md) - Project overview and introduction
- [SETUP.md](SETUP.md) - Detailed setup and configuration instructions
- [quickstart.py](quickstart.py) - Demo script to test tools
### Documentation
- [TOOL_REFERENCE.md](TOOL_REFERENCE.md) - Complete API reference for all 18 tools
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture and design
- [PROJECT_SUMMARY.md](PROJECT_SUMMARY.md) - Implementation summary
### Configuration
- [requirements.txt](requirements.txt) - Python dependencies
- [env.example](env.example) - Environment variables template
### Source Code
- [src/main.py](src/main.py) - MCP server entry point (18 tool registrations)
- [src/base.py](src/base.py) - Shared utilities and models
- [src/search_tools.py](src/search_tools.py) - Search functionality (3 tools)
- [src/multimodal_tools.py](src/multimodal_tools.py) - Multimodal processing (4 tools)
- [src/filesystem_tools.py](src/filesystem_tools.py) - File operations (3 tools)
- [src/public_data_tools.py](src/public_data_tools.py) - Public APIs (6 tools)
- [src/private_data_tools.py](src/private_data_tools.py) - Private data (2 tools)
### Testing
- [test_imports.py](test_imports.py) - Verify module imports
## Project Statistics
- **Total Files**: 17
- **Python Modules**: 8
- **Lines of Code**: ~2,128
- **Total Tools**: 18
- **Tool Categories**: 5
- **Documentation Pages**: 6
- **External APIs Integrated**: 8+
## Tool Categories Overview
### 🔍 Search Tools (3)
1. **web_search** - Google Custom Search
2. **download** - File downloads
3. **knowledge_base_search** - Local search
### 📄 Multimodal Understanding (4)
4. **webpage_reader** - Web content extraction
5. **document_reader** - PDF/DOCX/PPTX
6. **image_parser** - Image analysis
7. **video_parser** - Video metadata
### 📁 File System Tools (3)
8. **file_reader** - Read files
9. **grep** - Pattern search
10. **text_summarizer** - Summarization
### 🌐 Public Data Sources (6)
11. **weather** - Weather information
12. **stock_price** - Stock data
13. **currency_converter** - Currency conversion
14. **wikipedia_search** - Wikipedia
15. **arxiv_search** - Academic papers
16. **wayback_search** - Web archives
### 🔐 Private Data Sources (2)
17. **calendar_events** - Google Calendar
18. **notion_search** - Notion workspace
## API Dependencies
### Required (for core functionality)
- Google Custom Search API (web search)
- OpenWeather API (weather)
### Optional
- Google Calendar API (calendar events)
- Notion API (Notion search)
### No API Key Required
- Wikipedia
- ArXiv
- Yahoo Finance (stocks)
- Exchange Rate API (currency)
- Wayback Machine
## Common Tasks
### Installation
```bash
cd projects/week3/perception-tools
pip install -r requirements.txt
cp env.example .env
# Edit .env with your API keys
```
### Testing
```bash
python test_imports.py # Verify imports
python quickstart.py # Test functionality
```
### Running
```bash
cd src
python main.py # Start MCP server
```
### Adding to Claude Desktop
Edit config file and add:
```json
{
"mcpServers": {
"perception-tools": {
"command": "python",
"args": ["/path/to/perception-tools/src/main.py"]
}
}
}
```
## Documentation Structure
### For Users
1. Start with [README.md](README.md)
2. Follow [SETUP.md](SETUP.md) for configuration
3. Run [quickstart.py](quickstart.py) to test
4. Reference [TOOL_REFERENCE.md](TOOL_REFERENCE.md) for API details
### For Developers
1. Review [ARCHITECTURE.md](ARCHITECTURE.md) for design
2. Read [PROJECT_SUMMARY.md](PROJECT_SUMMARY.md) for implementation
3. Study source code in `src/` directory
4. Follow patterns when adding new tools
## File Purposes
| File | Purpose | Audience |
|------|---------|----------|
| README.md | Overview, features, basic usage | End users |
| SETUP.md | Installation and configuration | End users |
| TOOL_REFERENCE.md | Complete API documentation | End users, Developers |
| ARCHITECTURE.md | System design and structure | Developers |
| PROJECT_SUMMARY.md | Implementation details | Developers, Reviewers |
| INDEX.md | This file - navigation aid | Everyone |
| requirements.txt | Python dependencies | Installation |
| env.example | Configuration template | Configuration |
| quickstart.py | Demo and testing | Testing |
| test_imports.py | Import verification | Testing |
## Module Purposes
| Module | Lines | Tools | Purpose |
|--------|-------|-------|---------|
| main.py | ~370 | 18 | MCP server and tool registration |
| base.py | ~150 | - | Shared utilities and models |
| search_tools.py | ~320 | 3 | Search and download operations |
| multimodal_tools.py | ~360 | 4 | Document and media processing |
| filesystem_tools.py | ~280 | 3 | File system operations |
| public_data_tools.py | ~550 | 6 | Public API integrations |
| private_data_tools.py | ~180 | 2 | Private data sources |
## Key Design Decisions
1. **Modular Architecture**: Separate files for each category
2. **Async Throughout**: All tools use async/await
3. **Standardized Responses**: ActionResponse format everywhere
4. **Comprehensive Error Handling**: Try-except with detailed errors
5. **Configuration via Environment**: No hardcoded credentials
6. **Optional Dependencies**: Core tools work without all APIs
7. **Type Hints**: Full type annotation for IDE support
8. **Documentation**: Extensive inline and external docs
## Supported Formats
### Documents
- PDF, DOCX, PPTX, TXT, MD, JSON
### Images
- JPG, PNG, GIF, BMP, TIFF, WEBP
### Videos
- MP4, AVI, MOV, MKV, WEBM
### Web
- HTML, HTTP/HTTPS URLs
## External Service Integration
| Service | Tool | API Required | Status |
|---------|------|--------------|--------|
| Google Search | web_search | Yes | Implemented |
| OpenWeather | weather | Yes | Implemented |
| Yahoo Finance | stock_price | No | Implemented |
| Exchange Rate API | currency_converter | No | Implemented |
| Wikipedia | wikipedia_search | No | Implemented |
| ArXiv | arxiv_search | No | Implemented |
| Wayback Machine | wayback_search | No | Implemented |
| Google Calendar | calendar_events | Yes (OAuth2) | Implemented |
| Notion | notion_search | Yes | Implemented |
## Development Timeline
✅ Phase 1: Project structure and base utilities
✅ Phase 2: Search tools implementation
✅ Phase 3: Multimodal tools implementation
✅ Phase 4: File system tools implementation
✅ Phase 5: Public data tools implementation
✅ Phase 6: Private data tools implementation
✅ Phase 7: Documentation and testing
✅ Phase 8: Integration and verification
## Next Steps for Users
1. ✅ Read README.md
2. ⬜ Install dependencies
3. ⬜ Configure API keys
4. ⬜ Run test_imports.py
5. ⬜ Run quickstart.py
6. ⬜ Integrate with MCP client
7. ⬜ Start using tools!
## Support Resources
- **Documentation**: All .md files in this directory
- **Source Code**: Well-commented code in src/
- **Testing**: test_imports.py and quickstart.py
- **Configuration**: env.example with detailed comments
## License & Attribution
Part of the AI Agent Training Camp materials.
---
**Last Updated**: 2024
**Version**: 1.0.0
**Status**: Complete and ready for use
@@ -0,0 +1,250 @@
# Perception Tools MCP Server - Project Summary
## Overview
A comprehensive MCP (Model Context Protocol) server implementing 18 perception tools organized into 5 categories, following SOLID principles with a modular architecture.
## Implementation Details
### Architecture
The project follows the **Single Responsibility Principle** with separate modules for each tool category:
```
perception-tools/
├── src/
│ ├── base.py # Shared models and utilities
│ ├── search_tools.py # Search functionality (3 tools)
│ ├── multimodal_tools.py # Multimodal understanding (4 tools)
│ ├── filesystem_tools.py # File operations (3 tools)
│ ├── public_data_tools.py # Public APIs (6 tools)
│ ├── private_data_tools.py # Private data sources (2 tools)
│ └── main.py # MCP server entry point
├── requirements.txt # Dependencies
├── env.example # Configuration template
├── quickstart.py # Demo script
├── test_imports.py # Module verification
├── README.md # User documentation
├── SETUP.md # Setup instructions
└── TOOL_REFERENCE.md # Complete API reference
```
### Design Principles Applied
#### KISS (Keep It Simple, Stupid)
- Each tool has a single, clear purpose
- Simple async function signatures
- Straightforward error handling
#### DRY (Don't Repeat Yourself)
- Common utilities in `base.py` (ActionResponse, file validation, URL downloading)
- Shared error handling patterns
- Reusable Pydantic models
#### SOLID Principles
**Single Responsibility:**
- Each module handles one category of tools
- Base module provides shared functionality only
- Tools have single, well-defined purposes
**Open/Closed:**
- Easy to add new tools without modifying existing code
- Extensible through new modules
- MCP decorator pattern allows non-invasive tool registration
**Liskov Substitution:**
- All tools return consistent `ActionResponse` format
- Uniform error handling across all tools
**Interface Segregation:**
- Tools expose only necessary parameters
- Optional parameters with sensible defaults
- No forced dependencies on unused features
**Dependency Inversion:**
- Tools depend on abstractions (ActionResponse, TextContent)
- External services accessed through interfaces
- Configuration via environment variables
## Tool Categories
### 1. Search Tools (3 tools)
- `web_search`: Google Custom Search integration
- `download`: HTTP/HTTPS file downloads with safety checks
- `knowledge_base_search`: Local document search
### 2. Multimodal Understanding Tools (4 tools)
- `webpage_reader`: HTML content extraction
- `document_reader`: PDF/DOCX/PPTX processing
- `image_parser`: Image analysis with PIL
- `video_parser`: Video metadata extraction with OpenCV
### 3. File System Tools (3 tools)
- `file_reader`: File reading with encoding support
- `grep`: Regex pattern search in files
- `text_summarizer`: Text summarization (extractive/LLM)
### 4. Public Data Source Tools (6 tools)
- `weather`: OpenWeather API integration
- `stock_price`: Yahoo Finance data
- `currency_converter`: Exchange rate conversion
- `wikipedia_search`: Wikipedia API wrapper
- `arxiv_search`: Academic paper search
- `wayback_search`: Internet Archive access
### 5. Private Data Source Tools (2 tools)
- `calendar_events`: Google Calendar OAuth2 integration
- `notion_search`: Notion API wrapper
## Key Features
### Error Handling
- Consistent error response format
- Detailed error types for debugging
- Graceful degradation when services unavailable
### Configuration Management
- Environment variable based configuration
- Template file for easy setup
- Optional dependencies clearly marked
### Response Format
All tools return standardized JSON responses:
```json
{
"success": true/false,
"message": "Result data or error message",
"metadata": {
"additional": "context information"
}
}
```
### Safety Features
- File size limits for downloads
- Timeout controls for network operations
- Path validation to prevent directory traversal
- URL validation for external requests
## Testing
### Import Verification
```bash
python test_imports.py
```
### Functional Testing
```bash
python quickstart.py
```
### Manual MCP Server Testing
```bash
cd src && python main.py
```
## Dependencies
### Core
- `mcp`: MCP server framework
- `pydantic`: Data validation
- `python-dotenv`: Configuration management
- `requests`: HTTP client
### Document Processing
- `PyPDF2`: PDF parsing
- `python-docx`: Word documents
- `python-pptx`: PowerPoint presentations
- `Pillow`: Image processing
- `opencv-python`: Video processing
### Web Scraping
- `beautifulsoup4`: HTML parsing
- `lxml`: XML/HTML parser
### Data Sources
- `wikipedia`: Wikipedia API
- `arxiv`: ArXiv API
### Optional
- Google Calendar: `google-auth-*`, `google-api-python-client`
- Notion: `notion-client`
## Configuration Requirements
### Required for Full Functionality
- `GOOGLE_API_KEY`: For web search
- `GOOGLE_CSE_ID`: For web search
- `OPENWEATHER_API_KEY`: For weather data
### Optional
- `NOTION_API_KEY`: For Notion integration
- Google OAuth2 credentials: For Calendar integration
## Performance Considerations
- Default timeouts: 30-180 seconds depending on operation
- File size limits: 100MB for downloads, 500MB for videos
- Text truncation: 50,000 characters for file reading
- Result limits: Configurable per tool (typically 5-10 items)
## Future Enhancements
Potential additions:
1. LLM-based summarization integration
2. Image analysis with vision APIs
3. Video frame extraction and analysis
4. Database search integration
5. Email integration (Gmail, Outlook)
6. Slack/Discord integration
7. GitHub API integration
8. Real-time data streaming support
## MCP Integration
The server uses the MCP SDK v2 `MCPServer` with stdio transport, making it compatible with:
- Claude Desktop
- Other MCP-compatible clients
- Custom integration via stdio communication
## Documentation
Comprehensive documentation provided:
- `README.md`: Overview and quick start
- `SETUP.md`: Detailed setup instructions
- `TOOL_REFERENCE.md`: Complete API reference for all 18 tools
- `PROJECT_SUMMARY.md`: This file
## Code Quality
- Type hints throughout
- Comprehensive docstrings
- Consistent formatting
- Error handling at all levels
- Logging for debugging
## Maintenance
To add new tools:
1. Create function in appropriate module
2. Follow existing patterns (async, ActionResponse)
3. Register in `main.py` with `@mcp.tool` decorator
4. Update documentation
## Success Metrics
✅ 18 tools implemented across 5 categories
✅ Modular architecture following SOLID principles
✅ Comprehensive error handling
✅ Complete documentation
✅ Easy configuration and setup
✅ MCP-compatible server ready for production use
## Status
**Implementation: Complete**
**Documentation: Complete**
**Testing Framework: Complete**
**Ready for Use: Yes (with dependency installation)**
+217
View File
@@ -0,0 +1,217 @@
# Quick Start Guide - Perception Tools
## 🚀 Zero Setup Required!
All tools work immediately with **no API keys** needed.
## Installation
```bash
cd projects/week4/perception-tools
pip install -r requirements.txt
```
## Run Tests
```bash
# Test original tools
python quickstart.py
# Test new crypto/location/POI tools
python test_new_tools.py
```
## Usage Examples
### 1. Cryptocurrency Prices 💰
```python
from public_data_tools import get_crypto_price
# Get Bitcoin price in USD
result = await get_crypto_price("btc", "usd")
# Get Ethereum price in EUR
result = await get_crypto_price("eth", "eur")
# Supported: btc, eth, sol, ada, doge, bnb, xrp, usdt, usdc, etc.
```
### 2. Location Search 📍
```python
from public_data_tools import search_location
# Search any location
result = await search_location("Eiffel Tower", limit=5)
# Filter by country
result = await search_location("Paris", country_code="fr")
# Search businesses
result = await search_location("Starbucks in Seattle")
```
### 3. POI Search 🗺️
```python
from public_data_tools import search_poi
# Find restaurants near a location
result = await search_poi(
query="restaurant",
latitude=48.8584,
longitude=2.2945,
radius=500, # meters
limit=10
)
# Find cafes
result = await search_poi("cafe", 37.7749, -122.4194, radius=1000)
# Find hotels, hospitals, ATMs, etc.
result = await search_poi("hotel", lat, lon)
```
### 4. Weather ⛅
```python
from public_data_tools import get_weather
# Get weather by city name
result = await get_weather("London")
# Get weather by coordinates
result = await get_weather("Paris", latitude=48.8566, longitude=2.3522)
```
### 5. Web Search 🔍
```python
from search_tools import search_web
# Search the web
result = await search_web("Python programming", num_results=5)
# Regional search
result = await search_web("news", region="us-en")
```
### 6. Stock Prices 📈
```python
from public_data_tools import get_stock_price
# Get stock price
result = await get_stock_price("AAPL")
result = await get_stock_price("TSLA")
```
## All Available Free APIs
| Tool | Use Case | Example |
|------|----------|---------|
| 🔍 **Web Search** | Search the internet | `search_web("AI news")` |
| 🌤️ **Weather** | Current weather | `get_weather("Tokyo")` |
| 💰 **Crypto Prices** | Cryptocurrency data | `get_crypto_price("btc")` |
| 📈 **Stock Prices** | Stock market data | `get_stock_price("GOOGL")` |
| 💱 **Currency** | Exchange rates | `convert_currency(100, "USD", "EUR")` |
| 📍 **Location Search** | Find places | `search_location("Eiffel Tower")` |
| 🗺️ **POI Search** | Find nearby places | `search_poi("restaurant", lat, lon)` |
| 📚 **Wikipedia** | Encyclopedia | `search_wikipedia("AI")` |
| 🔬 **ArXiv** | Academic papers | `search_arxiv("deep learning")` |
| 🕰️ **Wayback Machine** | Archived pages | `search_wayback("example.com")` |
## Common Use Cases
### Travel Planning
```python
# 1. Find a city
location = await search_location("Paris, France")
lat, lon = location['latitude'], location['longitude']
# 2. Check weather
weather = await get_weather("Paris")
# 3. Find hotels
hotels = await search_poi("hotel", lat, lon, radius=2000)
# 4. Find restaurants
restaurants = await search_poi("restaurant", lat, lon, radius=1000)
# 5. Convert currency
cost = await convert_currency(100, "USD", "EUR")
```
### Investment Research
```python
# 1. Get stock price
stock = await get_stock_price("AAPL")
# 2. Get crypto prices
btc = await get_crypto_price("btc")
eth = await get_crypto_price("eth")
# 3. Check currency rates
rate = await convert_currency(1, "USD", "EUR")
# 4. Research on Wikipedia
info = await search_wikipedia("Apple Inc")
```
### Content Research
```python
# 1. Web search
results = await search_web("climate change 2024")
# 2. Academic papers
papers = await search_arxiv("climate change")
# 3. Wikipedia
wiki = await search_wikipedia("Climate change")
# 4. Historical data
archive = await search_wayback("ipcc.ch", year=2020)
```
## Response Format
All tools return a standardized JSON response:
```json
{
"success": true,
"message": {
// Tool-specific data here
},
"metadata": {
"provider": "API name",
"api_key_required": false
}
}
```
## Tips & Best Practices
1. **Rate Limiting**: Be respectful of free APIs - don't make excessive requests
2. **Caching**: Cache results when possible to reduce API calls
3. **Error Handling**: Always check the `success` field in responses
4. **User Agent**: Tools use appropriate User-Agent headers for API compliance
## Need Help?
- 📖 See `README.md` for full documentation
- 🔄 See `CHANGES.md` for what's new
- 🧪 Run `python test_new_tools.py` to verify everything works
## API Credits
- [Open-Meteo](https://open-meteo.com/) - Weather data
- [CoinGecko](https://www.coingecko.com/) - Crypto prices
- [OpenStreetMap](https://www.openstreetmap.org/) - Maps & POI data
- [DuckDuckGo](https://duckduckgo.com/) - Web search
- [Yahoo Finance](https://finance.yahoo.com/) - Stock prices
- [ExchangeRate-API](https://www.exchangerate-api.com/) - Currency rates
+857
View File
@@ -0,0 +1,857 @@
# Perception Tools MCP Server / 感知工具 MCP 服务器
> Companion code for *AI Agents in Depth*, Chapter 4 — **Experiment 4-1 ★★**. MCP perception tools: search, multimodal, filesystem, public/private data. Most free APIs need no key.
> 配套《深入理解 AI Agent》第 4 章 **实验 4-1 ★★**。感知 MCP 工具:搜索、多模态、文件系统、公开/私有数据。多数免费 API 无需 Key。
← [Chapter 4 index / 返回第 4 章目录](../README.md)
---
## English
A comprehensive MCP (Model Context Protocol) server providing various perception and data retrieval capabilities for AI agents.
### Features
> **✨ No API Keys Required!** Most features work out-of-the-box with free, open APIs.
#### Search Tools
- **Web Search**: DuckDuckGo search (free, no API key required)
- **Knowledge Base Search**: Search local document collections
- **File Download**: Download files from URLs with safety checks
#### Multimodal Understanding Tools
- **Web Page Reader**: Extract text and links from web pages
- **Document Reader**: Extract content from PDF, DOCX, PPTX files
- **Image Parser**: Parse and analyze image files
- **Video Parser**: Extract metadata from video files
#### File System Tools
- **File Reader**: Read files with encoding support
- **Grep Search**: Search for patterns in files (regex support)
- **Text Summarization**: Summarize long text content
- **Directory Browser**: Bounded directory listing/tree operations
- **Safe Move / Copy / Delete**: Relative paths only beneath an explicit
`PERCEPTION_MUTATION_ROOT`; traversal, absolute paths, and symlinks are
rejected, while delete/overwrite use reversible quarantine
#### Public Data Sources
- **Weather**: Current weather via [Open-Meteo](https://open-meteo.com/) (free, no API key)
- **Stock Prices**: Real-time stock data from Yahoo Finance (free, no API key)
- **Crypto Prices**: Cryptocurrency prices via [CoinGecko](https://www.coingecko.com/) (free, no API key)
- **Currency Conversion**: Convert between currencies (free, no API key)
- **Location Search**: Geocoding via [Nominatim (OpenStreetMap)](https://nominatim.openstreetmap.org/) (free, no API key)
- **POI Search**: Points of Interest via [Overpass API (OpenStreetMap)](https://overpass-api.de/) (free, no API key)
- **Wikipedia**: Search and retrieve Wikipedia articles (free, no API key)
- **ArXiv**: Search academic papers on ArXiv (free, no API key)
- **Wayback Machine**: Access archived web pages (free, no API key)
#### Private Data Sources
- **Google Calendar**: Query calendar events
- **Notion**: Search Notion workspace
### Installation
1. Create a clean environment for Experiment 4-1 and install its MCP v2 dependencies:
```bash
cd chapter4/perception-tools
python -m venv .venv
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
python -m pip install -r requirements.txt
# Offline protocol smoke test: starts stdio, lists tools, and calls file_reader.
python smoke_test_mcp_v2.py
```
`requirements.txt` deliberately pins `mcp>=2,<3`. Experiment 4-1 uses the
SDK v2 `MCPServer`/`Client` API and negotiates the stateless MCP
`2026-07-28` protocol. A shared environment that still contains MCP 1.x is
not compatible with this experiment.
2. **No additional configuration required!** The server works out-of-the-box with free APIs.
### Configuration
#### Default Free APIs (No Setup Required)
The following features work immediately without any API keys:
- **Web Search**: DuckDuckGo
- **Weather**: Open-Meteo
- **Stock Prices**: Yahoo Finance
- **Crypto Prices**: CoinGecko
- **Currency Conversion**: ExchangeRate-API
- **Location Search**: Nominatim (OpenStreetMap)
- **POI Search**: Overpass API (OpenStreetMap)
- **Wikipedia**: Wikipedia API
- **ArXiv**: ArXiv API
- **Wayback Machine**: Internet Archive
#### Optional Private Data Integrations
##### Google Calendar
For Google Calendar integration, you need to set up OAuth2:
Install the Google API client/auth packages separately if you want to enable this optional integration; the base requirements keep it optional.
Follow the [Google Calendar API quickstart](https://developers.google.com/calendar/api/quickstart/python) to set up OAuth2 credentials.
##### Notion
1. Create a Notion integration at [notion.so/my-integrations](https://www.notion.so/my-integrations)
2. Get your integration token
3. Share your databases/pages with the integration
4. Add `NOTION_API_KEY` to `.env`
Install `notion-client` separately if you want to enable this optional integration; the base requirements keep it optional.
#### Safe filesystem mutations
The read-only filesystem tools need no configuration. Move, copy, and delete
fail closed until an explicit disposable workspace is configured:
```bash
export PERCEPTION_MUTATION_ROOT=/absolute/path/to/disposable/workspace
```
Mutation arguments remain relative to that root. The server records pre/post
SHA-256 fingerprints, rejects `..`, absolute paths, and symlinks, and moves
deleted/replaced data into `.perception-trash` so the operation is reversible.
### Exact Experiment 4-1 campaign
Run the five-category campaign through the real MCP stdio transport:
```bash
python run_experiment_4_1.py
python -m pip install pytest pytest-asyncio
python -m pytest -q test_experiment_4_1.py test_filesystem_mutations.py \
test_real_experiment_4_1_evidence.py test_expanded_catalog.py
```
The retained July 30, 2026 receipt is **legacy evidence**: it predates SDK v2
and did not record either the installed `mcp` version or the negotiated
protocol version, so it is not proof of the current protocol migration. Its
business-tool outcome is intentionally **blocked**, not passed:
search, filesystem, and public-data categories passed; local webpage/document,
OCR, Whisper, and video parsing also passed; image/video AI analysis received
OpenAI `insufficient_quota`, while Calendar and Notion lacked authorization.
Those four calls remain failed evidence and cannot satisfy the corresponding
category gates. New runs record `mcp_sdk_version` and `protocol_version` in
`catalog_receipt.json`; the catalog gate accepts only SDK 2.x negotiated to
`2026-07-28`.
### Usage
#### Running the MCP Server
```bash
cd src
python main.py
```
The server runs using stdio transport, suitable for integration with MCP clients.
#### Command-Line Interface (`cli.py`)
Besides serving over MCP stdio, the repo root provides a unified CLI `cli.py` to list, inspect, call, and demo perception tools without an MCP client. Tools are organized by the five Chapter 4 perception scenarios: search / multimodal / filesystem / public data / private data (**53 tools** currently).
```bash
# Help (Chinese)
python cli.py --help
# List all perception tools by five categories (--category for one class)
python cli.py list
python cli.py list --category filesystem
# Parameter signature and call example for a tool
python cli.py info weather
# Call a tool; args as key=value; result is standard ActionResponse JSON
python cli.py run grep 'pattern=async def' directory=src 'file_pattern=*.py'
python cli.py run currency_converter amount=100 from_currency=USD to_currency=CNY
# End-to-end demo: research-assistant perception flow (local + external info)
python cli.py demo # full demo (includes network steps)
python cli.py demo --offline # offline (filesystem / local KB only)
```
Notes:
- Each tool is async and returns a unified `ActionResponse` (JSON); the CLI runs the event loop, parses JSON, and prints friendly output.
- Tools are lazy-imported: `list` / `info` / offline `demo` still work if optional deps (e.g. `whisper`, `waybackpy`) are missing; modules load only when those tools are actually called.
- Network tools are marked「联网」in `list`; tools needing auth/API keys are annotated accordingly.
#### Using with MCP Clients
Configure your MCP client (e.g., Claude Desktop) to connect to this server:
```json
{
"mcpServers": {
"perception-tools": {
"command": "python",
"args": ["/path/to/perception-tools/src/main.py"]
}
}
}
```
### Available Tools
#### Search Tools
##### `web_search`
Search the web using DuckDuckGo (free, no API key required).
Parameters:
- `query` (str): Search query string
- `num_results` (int, default=5): Number of results (1-10)
- `region` (str, default="wt-wt"): Region code (e.g., "us-en", "uk-en", "wt-wt" for worldwide)
##### `download`
Download a file from a URL.
Parameters:
- `url` (str): URL to download from
- `output_path` (str): Local path to save the file
- `overwrite` (bool, default=False): Overwrite existing file
- `timeout` (int, default=180): Download timeout in seconds
##### `knowledge_base_search`
Search a local knowledge base directory.
Parameters:
- `query` (str): Search query
- `knowledge_base_path` (str): Path to knowledge base directory
- `top_k` (int, default=5): Number of top results
#### Multimodal Understanding Tools
##### `webpage_reader`
Read and extract content from a webpage.
Parameters:
- `url` (str): URL of the webpage
- `extract_text` (bool, default=True): Extract text content
- `extract_links` (bool, default=False): Extract links
##### `document_reader`
Read and extract content from documents (PDF, DOCX, PPTX).
Parameters:
- `file_path` (str): Path to document file or URL
- `extract_images` (bool, default=False): Extract images
##### `image_parser`
Parse and analyze image files.
Parameters:
- `image_path` (str): Path to image file or URL
- `use_llm` (bool, default=True): Use LLM for analysis
> **Vision LLM keys / OpenRouter fallback**: AI image/video analysis
> (`analyze_image_ai` / `analyze_video_ai`) use `OPENAI_API_KEY` when set.
> If it is absent but `OPENROUTER_API_KEY` is set, they transparently route
> through OpenRouter (`base_url=https://openrouter.ai/api/v1`, model mapped to
> `provider/model` form). Override the model via `PERCEPTION_VISION_MODEL`.
> (Local Whisper transcription still needs `OPENAI_API_KEY` — OpenRouter has no
> audio-transcription API.)
> Gemini is also supported through its OpenAI-compatible endpoint: set
> `GEMINI_API_KEY`, `PERCEPTION_VISION_PROVIDER=gemini`, and optionally
> `PERCEPTION_VISION_MODEL` (the campaign uses `gemini-2.5-flash`).
##### `video_parser`
Parse and extract metadata from video files.
Parameters:
- `video_path` (str): Path to video file or URL
- `extract_frames` (bool, default=False): Extract sample frames
- `frame_interval` (int, default=30): Frame extraction interval
#### File System Tools
##### `file_reader`
Read a file and return its contents.
Parameters:
- `file_path` (str): Path to the file
- `encoding` (str, default="utf-8"): File encoding
- `max_length` (int, default=50000): Maximum characters to read
##### `grep`
Search for patterns in files (grep-like functionality).
Parameters:
- `pattern` (str): Regular expression pattern
- `directory` (str): Directory to search in
- `file_pattern` (str, default="*"): File pattern (e.g., *.py)
- `recursive` (bool, default=True): Search recursively
- `case_sensitive` (bool, default=False): Case-sensitive search
- `max_results` (int, default=100): Maximum results
##### `text_summarizer`
Summarize long text content.
Parameters:
- `text` (str): Text to summarize
- `max_length` (int, default=500): Target summary length
- `use_llm` (bool, default=True): Use LLM for summarization
#### Public Data Source Tools
##### `weather`
Get current weather information using Open-Meteo API (free, no API key required).
Parameters:
- `location` (str): City name (automatically geocoded)
- `latitude` (float, optional): Latitude coordinate
- `longitude` (float, optional): Longitude coordinate
##### `stock_price`
Get stock price and market information using Yahoo Finance (free, no API key required).
Parameters:
- `symbol` (str): Stock ticker symbol (e.g., AAPL, TSLA, GOOGL)
- `interval` (str, default="1d"): Data interval
##### `crypto_price`
Get cryptocurrency price information using CoinGecko API (free, no API key required).
Parameters:
- `symbol` (str): Cryptocurrency symbol or ID (e.g., bitcoin, ethereum, btc, eth)
- `vs_currency` (str, default="usd"): Target currency (usd, eur, gbp, etc.)
##### `currency_converter`
Convert between currencies.
Parameters:
- `amount` (float): Amount to convert
- `from_currency` (str): Source currency code (e.g., USD)
- `to_currency` (str): Target currency code (e.g., EUR)
##### `wikipedia_search`
Search Wikipedia and get article summary.
Parameters:
- `query` (str): Search query
- `language` (str, default="en"): Wikipedia language
- `sentences` (int, default=5): Summary sentence count
##### `arxiv_search`
Search ArXiv for academic papers.
Parameters:
- `query` (str): Search query
- `max_results` (int, default=5): Maximum results
- `sort_by` (str, default="relevance"): Sort method
##### `wayback_search`
Search Wayback Machine for archived web pages.
Parameters:
- `url` (str): URL to search for
- `year` (int, optional): Filter by year
- `limit` (int, default=10): Maximum snapshots
##### `location_search`
Search for locations using Nominatim (OpenStreetMap) API (free, no API key required).
Parameters:
- `query` (str): Location query (e.g., "Eiffel Tower", "New York", "Tokyo")
- `limit` (int, default=5): Maximum number of results (1-50)
- `country_code` (str, optional): Country code filter (e.g., "us", "gb", "fr")
##### `poi_search`
Search for Points of Interest near a location using Overpass API (free, no API key required).
Parameters:
- `query` (str): Type of POI (e.g., "restaurant", "cafe", "hospital", "atm", "hotel")
- `latitude` (float): Center latitude coordinate
- `longitude` (float): Center longitude coordinate
- `radius` (int, default=1000): Search radius in meters
- `limit` (int, default=10): Maximum number of results
#### Private Data Source Tools
##### `calendar_events`
Get events from Google Calendar.
Parameters:
- `start_date` (str, optional): Start date (ISO format)
- `end_date` (str, optional): End date (ISO format)
- `calendar_id` (str, default="primary"): Calendar ID
- `max_results` (int, default=10): Maximum events
##### `notion_search`
Search Notion workspace.
Parameters:
- `query` (str): Search query
- `database_id` (str, optional): Specific database ID
- `page_size` (int, default=10): Results per page
### Architecture
The project follows SOLID principles with a modular architecture:
```
perception-tools/
├── src/
│ ├── main.py # MCP server entry point
│ ├── base.py # Base models and utilities
│ ├── search_tools.py # Search functionality
│ ├── multimodal_tools.py # Document/media processing
│ ├── filesystem_tools.py # File operations
│ ├── public_data_tools.py # Public APIs
│ └── private_data_tools.py # Private data sources
├── requirements.txt # Python dependencies
├── env.example # Environment variables template
└── README.md # This file
```
### Error Handling
All tools return a standardized `ActionResponse` format:
```json
{
"success": true/false,
"message": "Result data or error message",
"metadata": {
"additional": "context information"
}
}
```
### Contributing
Contributions are welcome! Please ensure:
1. Code follows KISS, DRY, and SOLID principles
2. All tools return standardized ActionResponse format
3. Proper error handling and logging
4. Documentation for new tools
### License
This project is part of the AI Agent training camp materials.
---
## 中文
为 AI Agent 提供多种感知与数据获取能力的综合 MCPModel Context Protocol)服务器。
### 功能
> **✨ 多数功能无需 API Key!** 基于免费开放 API,开箱即用。
#### 搜索工具
- **网络搜索**DuckDuckGo(免费,无需 API Key
- **知识库搜索**:搜索本地文档集合
- **文件下载**:从 URL 下载,带安全检查
#### 多模态理解工具
- **网页阅读**:抽取文本与链接
- **文档阅读**PDF、DOCX、PPTX
- **图像解析**:解析与分析图像
- **视频解析**:抽取视频元数据
#### 文件系统工具
- **文件阅读**:支持编码
- **Grep 搜索**:正则匹配文件内容
- **文本摘要**:总结长文本
#### 公开数据源
- **天气**[Open-Meteo](https://open-meteo.com/)(免费,无需 Key
- **股价**Yahoo Finance(免费,无需 Key
- **加密货币**[CoinGecko](https://www.coingecko.com/)(免费,无需 Key
- **汇率换算**:货币转换(免费,无需 Key)
- **地点搜索**[Nominatim (OpenStreetMap)](https://nominatim.openstreetmap.org/)(免费,无需 Key
- **POI 搜索**[Overpass API (OpenStreetMap)](https://overpass-api.de/)(免费,无需 Key
- **Wikipedia**:检索维基条目(免费,无需 Key)
- **ArXiv**:学术论文检索(免费,无需 Key)
- **Wayback Machine**:历史网页存档(免费,无需 Key)
#### 私有数据源
- **Google Calendar**:查询日历事件
- **Notion**:搜索 Notion 工作区
### 安装
1. 为实验 4-1 创建干净环境并安装 MCP v2 依赖:
```bash
cd chapter4/perception-tools
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell.venv\Scripts\Activate.ps1
# Windows cmd.venv\Scripts\activate.bat
python -m pip install -r requirements.txt
# 离线协议冒烟测试:启动 stdio、列出工具并调用 file_reader
python smoke_test_mcp_v2.py
```
`requirements.txt` 明确限定 `mcp>=2,<3`。实验 4-1 使用 SDK v2 的
`MCPServer`/`Client` API,并协商无状态的 MCP `2026-07-28` 协议;仍安装
MCP 1.x 的共享环境与本实验不兼容。
2. **无需额外配置!** 服务器默认即可用免费 API 工作。
### 配置
#### 默认免费 API(无需配置)
以下功能立即可用,无需任何 API Key:
- **网络搜索**DuckDuckGo
- **天气**Open-Meteo
- **股价**Yahoo Finance
- **加密货币**CoinGecko
- **汇率换算**ExchangeRate-API
- **地点搜索**NominatimOpenStreetMap
- **POI 搜索**Overpass APIOpenStreetMap
- **Wikipedia**Wikipedia API
- **ArXiv**ArXiv API
- **Wayback Machine**Internet Archive
#### 可选私有数据集成
##### Google Calendar
需要配置 OAuth2
如需启用该可选集成,请另行安装 Google API client/auth 包;基础依赖保持其可选性。
按 [Google Calendar API quickstart](https://developers.google.com/calendar/api/quickstart/python) 配置凭据。
##### Notion
1. 在 [notion.so/my-integrations](https://www.notion.so/my-integrations) 创建集成
2. 获取 integration token
3. 将数据库/页面共享给该集成
4.`.env` 中加入 `NOTION_API_KEY`
如需启用该可选集成,请另行安装 `notion-client`;基础依赖保持其可选性。
### 精确实验 4-1 campaign
通过真实 MCP stdio 传输运行五类场景:
```bash
python run_experiment_4_1.py
python -m pip install pytest pytest-asyncio
python -m pytest -q test_experiment_4_1.py test_filesystem_mutations.py \
test_real_experiment_4_1_evidence.py test_expanded_catalog.py
```
保留的 2026 年 7 月 30 日收据属于旧版证据:它早于 SDK v2,且没有记录
`mcp` 包版本或实际协商的协议版本,因此不能证明当前迁移已通过。新的运行器会在
`catalog_receipt.json` 中同时记录 `mcp_sdk_version``protocol_version`
并且只有 SDK 2.x 与协议 `2026-07-28` 才能通过 catalog gate。
### 使用
#### 运行 MCP 服务器
```bash
cd src
python main.py
```
服务器使用 stdio 传输,适合接入 MCP 客户端。
#### 命令行接口(`cli.py`
除了以 MCP stdio 协议对外服务,仓库根目录提供了一个统一的命令行入口
`cli.py`,无需 MCP 客户端即可直接列出、查看、调用和演示各类感知工具。
工具按第四章「感知工具」的五类场景组织:搜索 / 多模态理解 / 文件系统 /
公开数据源 / 私有数据源(当前共 53 个工具)。
```bash
# 查看帮助(中文)
python cli.py --help
# 按五类列出全部感知工具(可用 --category 只看某一类)
python cli.py list
python cli.py list --category filesystem
# 查看某个工具的参数签名与调用示例
python cli.py info weather
# 直接调用某个工具,参数以 key=value 形式传入,结果为标准 ActionResponse JSON
python cli.py run grep 'pattern=async def' directory=src 'file_pattern=*.py'
python cli.py run currency_converter amount=100 from_currency=USD to_currency=CNY
# 运行端到端演示:串联「本地资料 + 外部信息」的研究助手 Agent 感知流程
python cli.py demo # 完整演示(含联网步骤)
python cli.py demo --offline # 离线演示(只跑文件系统 / 本地知识库等不联网步骤)
```
说明:
- 每个工具都是异步函数,返回统一的 `ActionResponse`JSON);CLI 负责运行事件
循环、解析 JSON 并友好打印。
- 工具按需惰性导入:`list` / `info` / 离线 `demo` 在缺少可选依赖(如 `whisper`
`waybackpy`)时仍可正常工作,只有真正调用相关工具时才导入对应模块。
- 需要联网的工具在 `list` 中标注「联网」,需要授权/API Key 的工具标注了对应说明。
#### 与 MCP 客户端联用
在 MCP 客户端(如 Claude Desktop)中配置:
```json
{
"mcpServers": {
"perception-tools": {
"command": "python",
"args": ["/path/to/perception-tools/src/main.py"]
}
}
}
```
### 可用工具
#### 搜索工具
##### `web_search`
使用 DuckDuckGo 搜索(免费,无需 API Key)。
参数:
- `query` (str):搜索查询
- `num_results` (int, default=5):结果数(1-10
- `region` (str, default="wt-wt"):区域代码(如 `"us-en"``"uk-en"`、全球 `"wt-wt"`
##### `download`
从 URL 下载文件。
参数:
- `url` (str):下载地址
- `output_path` (str):本地保存路径
- `overwrite` (bool, default=False):是否覆盖已有文件
- `timeout` (int, default=180):超时秒数
##### `knowledge_base_search`
搜索本地知识库目录。
参数:
- `query` (str):搜索查询
- `knowledge_base_path` (str):知识库目录路径
- `top_k` (int, default=5):返回条数
#### 多模态理解工具
##### `webpage_reader`
读取并抽取网页内容。
参数:
- `url` (str):网页 URL
- `extract_text` (bool, default=True):是否抽取文本
- `extract_links` (bool, default=False):是否抽取链接
##### `document_reader`
读取文档(PDF、DOCX、PPTX)。
参数:
- `file_path` (str):文件路径或 URL
- `extract_images` (bool, default=False):是否抽取图片
##### `image_parser`
解析与分析图像。
参数:
- `image_path` (str):图像路径或 URL
- `use_llm` (bool, default=True):是否用 LLM 分析
> **视觉 LLM Key / OpenRouter 兜底**AI 图像/视频分析
> `analyze_image_ai` / `analyze_video_ai`)在设置了 `OPENAI_API_KEY` 时使用它。
> 若缺失但设置了 `OPENROUTER_API_KEY`,则透明走 OpenRouter
> `base_url=https://openrouter.ai/api/v1`,模型映射为 `provider/model`)。
> 可用 `PERCEPTION_VISION_MODEL` 覆盖模型。
> (本地 Whisper 转写仍需 `OPENAI_API_KEY`——OpenRouter 无音频转写 API。)
##### `video_parser`
解析并抽取视频元数据。
参数:
- `video_path` (str):视频路径或 URL
- `extract_frames` (bool, default=False):是否抽取样帧
- `frame_interval` (int, default=30):抽帧间隔
#### 文件系统工具
##### `file_reader`
读取文件内容。
参数:
- `file_path` (str):文件路径
- `encoding` (str, default="utf-8"):编码
- `max_length` (int, default=50000):最大字符数
##### `grep`
在文件中搜索模式(类 grep)。
参数:
- `pattern` (str):正则表达式
- `directory` (str):搜索目录
- `file_pattern` (str, default="*"):文件模式(如 `*.py`
- `recursive` (bool, default=True):是否递归
- `case_sensitive` (bool, default=False):是否区分大小写
- `max_results` (int, default=100):最大结果数
##### `text_summarizer`
总结长文本。
参数:
- `text` (str):待总结文本
- `max_length` (int, default=500):目标摘要长度
- `use_llm` (bool, default=True):是否用 LLM 总结
#### 公开数据源工具
##### `weather`
Open-Meteo 当前天气(免费,无需 Key)。
参数:
- `location` (str):城市名(自动地理编码)
- `latitude` (float, optional):纬度
- `longitude` (float, optional):经度
##### `stock_price`
Yahoo Finance 股价与行情(免费,无需 Key)。
参数:
- `symbol` (str):股票代码(如 AAPL、TSLA、GOOGL
- `interval` (str, default="1d"):数据间隔
##### `crypto_price`
CoinGecko 加密货币价格(免费,无需 Key)。
参数:
- `symbol` (str):符号或 ID(如 bitcoin、ethereum、btc、eth
- `vs_currency` (str, default="usd"):目标货币
##### `currency_converter`
货币换算。
参数:
- `amount` (float):金额
- `from_currency` (str):源货币(如 USD
- `to_currency` (str):目标货币(如 EUR
##### `wikipedia_search`
搜索 Wikipedia 并取摘要。
参数:
- `query` (str):搜索查询
- `language` (str, default="en"):语言
- `sentences` (int, default=5):摘要句数
##### `arxiv_search`
搜索 ArXiv 论文。
参数:
- `query` (str):搜索查询
- `max_results` (int, default=5):最大条数
- `sort_by` (str, default="relevance"):排序方式
##### `wayback_search`
搜索 Wayback Machine 历史快照。
参数:
- `url` (str):目标 URL
- `year` (int, optional):按年过滤
- `limit` (int, default=10):最大快照数
##### `location_search`
NominatimOpenStreetMap)地点搜索(免费,无需 Key)。
参数:
- `query` (str):地点查询(如 "Eiffel Tower"、"New York"、"Tokyo"
- `limit` (int, default=5):最大结果数(1-50
- `country_code` (str, optional):国家代码过滤(如 "us"、"gb"、"fr"
##### `poi_search`
Overpass API 附近 POI 搜索(免费,无需 Key)。
参数:
- `query` (str)POI 类型(如 "restaurant"、"cafe"、"hospital"、"atm"、"hotel"
- `latitude` (float):中心纬度
- `longitude` (float):中心经度
- `radius` (int, default=1000):搜索半径(米)
- `limit` (int, default=10):最大结果数
#### 私有数据源工具
##### `calendar_events`
从 Google Calendar 获取事件。
参数:
- `start_date` (str, optional):开始日期(ISO
- `end_date` (str, optional):结束日期(ISO
- `calendar_id` (str, default="primary"):日历 ID
- `max_results` (int, default=10):最大事件数
##### `notion_search`
搜索 Notion 工作区。
参数:
- `query` (str):搜索查询
- `database_id` (str, optional):指定数据库 ID
- `page_size` (int, default=10):每页条数
### 架构
项目遵循 SOLID,模块化组织:
```
perception-tools/
├── src/
│ ├── main.py # MCP server entry point
│ ├── base.py # Base models and utilities
│ ├── search_tools.py # Search functionality
│ ├── multimodal_tools.py # Document/media processing
│ ├── filesystem_tools.py # File operations
│ ├── public_data_tools.py # Public APIs
│ └── private_data_tools.py # Private data sources
├── requirements.txt # Python dependencies
├── env.example # Environment variables template
└── README.md # This file
```
### 错误处理
所有工具返回统一的 `ActionResponse`
```json
{
"success": true/false,
"message": "Result data or error message",
"metadata": {
"additional": "context information"
}
}
```
### 贡献
欢迎贡献。请确保:
1. 代码遵循 KISS、DRY、SOLID
2. 工具返回统一 ActionResponse
3. 妥善错误处理与日志
4. 为新工具补充文档
### 许可证
本项目为 AI Agent 训练营材料的一部分。
---
## Notes / 说明
- Prefer `python cli.py demo --offline` for a first run without network-heavy steps.
- 首次可先跑 `python cli.py demo --offline`,避免重度联网步骤。
- Most public-data tools need no API key; vision LLM and Whisper paths may need keys.
- 多数公开数据工具无需 Key;视觉 LLM 与 Whisper 路径可能需要 Key。
+199
View File
@@ -0,0 +1,199 @@
# Setup Guide
## Quick Setup
1. **Navigate to the project directory:**
```bash
cd projects/week3/perception-tools
```
2. **Install dependencies:**
```bash
pip install -r requirements.txt
```
3. **Configure environment variables:**
```bash
cp env.example .env
# Edit .env with your API keys
```
4. **Test the installation:**
```bash
python test_imports.py
```
5. **Run the quickstart demo:**
```bash
python quickstart.py
```
6. **Start the MCP server:**
```bash
python src/main.py
```
## Detailed API Setup
### Google Custom Search (Required for web search)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project
3. Enable "Custom Search API"
4. Create an API key in "Credentials"
5. Go to [Programmable Search Engine](https://programmablesearchengine.google.com/)
6. Create a new search engine
7. Configure it to search the entire web
8. Get your Search Engine ID (cx parameter)
9. Add to `.env`:
```
GOOGLE_API_KEY=your_api_key
GOOGLE_CSE_ID=your_search_engine_id
```
### OpenWeather API (Required for weather)
1. Sign up at [OpenWeatherMap](https://openweathermap.org/api)
2. Get your API key from the dashboard
3. Add to `.env`:
```
OPENWEATHER_API_KEY=your_api_key
```
### Notion API (Optional)
1. Go to [Notion Integrations](https://www.notion.so/my-integrations)
2. Create a new integration
3. Copy the "Internal Integration Token"
4. Share your databases/pages with the integration
5. Install the Notion SDK:
```bash
pip install notion-client
```
6. Add to `.env`:
```
NOTION_API_KEY=your_integration_token
```
### Google Calendar API (Optional)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Enable "Google Calendar API"
3. Create OAuth 2.0 credentials
4. Download the credentials JSON file
5. Install required packages:
```bash
pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client
```
6. Run the OAuth flow (first time only):
```python
# This will open a browser for authentication
# The token will be saved to ~/.perception-tools/google_token.pickle
```
## Using with MCP Clients
### Claude Desktop Configuration
Edit your Claude Desktop config file:
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
Add the server configuration:
```json
{
"mcpServers": {
"perception-tools": {
"command": "python",
"args": ["/absolute/path/to/perception-tools/src/main.py"],
"env": {
"GOOGLE_API_KEY": "your_key",
"GOOGLE_CSE_ID": "your_cse_id",
"OPENWEATHER_API_KEY": "your_key"
}
}
}
}
```
### Other MCP Clients
The server uses stdio transport and can be integrated with any MCP-compatible client. Refer to your client's documentation for configuration details.
## Troubleshooting
### Import Errors
If you see import errors, make sure all dependencies are installed:
```bash
pip install -r requirements.txt
```
### API Errors
If API calls fail:
1. Check that your API keys are correctly set in `.env`
2. Verify your API quotas haven't been exceeded
3. Check the API service status
### File Permission Errors
Ensure the script has write permissions for:
- Download directory (for file downloads)
- `~/.perception-tools/` (for OAuth tokens)
### Module Not Found
If Python can't find modules, ensure you're running from the correct directory or adjust your PYTHONPATH:
```bash
export PYTHONPATH="${PYTHONPATH}:/path/to/perception-tools/src"
```
## Development
### Running Tests
```bash
# Test imports
python test_imports.py
# Test tools
python quickstart.py
```
### Adding New Tools
1. Choose the appropriate module (or create a new one)
2. Implement the tool function following the pattern:
```python
async def my_tool(param: str) -> Union[str, TextContent]:
try:
# Implementation
return TextContent(...)
except Exception as e:
# Error handling
return TextContent(...)
```
3. Register the tool in `main.py` using `@mcp.tool` decorator
4. Update documentation
### Code Style
- Follow KISS, DRY, and SOLID principles
- Use type hints
- Include docstrings for all functions
- Return standardized ActionResponse format
- Include comprehensive error handling
## Support
For issues and questions:
1. Check this setup guide
2. Review the main README.md
3. Check tool-specific documentation
4. Review API provider documentation
+640
View File
@@ -0,0 +1,640 @@
# Tool Reference Guide
Complete reference for all 22 perception tools available in this MCP server.
## Table of Contents
- [Search Tools (3)](#search-tools)
- [Multimodal Understanding Tools (4)](#multimodal-understanding-tools)
- [File System Tools (3)](#file-system-tools)
- [Public Data Source Tools (6)](#public-data-source-tools)
- [Private Data Source Tools (2)](#private-data-source-tools)
---
## Search Tools
### 1. web_search
Search the web using Google Custom Search API.
**Parameters:**
- `query` (string, required): Search query string
- `num_results` (int, default: 5): Number of results to return (1-10)
- `language` (string, default: "en"): Language code (en, zh, es, etc.)
- `country` (string, default: "us"): Country code (us, cn, uk, etc.)
**Returns:**
```json
{
"success": true,
"message": {
"query": "Python programming",
"results": [
{
"id": "google-0",
"title": "Python.org",
"url": "https://www.python.org",
"snippet": "Official Python website...",
"source": "google"
}
],
"count": 5
},
"metadata": {
"query": "Python programming",
"search_engine": "google",
"total_results": 5,
"search_time": 0.45
}
}
```
**Requirements:** Google API Key, Google CSE ID
---
### 2. download
Download a file from a URL to local storage.
**Parameters:**
- `url` (string, required): HTTP/HTTPS URL to download from
- `output_path` (string, required): Local path to save the file
- `overwrite` (bool, default: false): Whether to overwrite existing files
- `timeout` (int, default: 180): Download timeout in seconds
**Returns:**
```json
{
"success": true,
"message": "Successfully downloaded file to /path/to/file.pdf",
"metadata": {
"url": "https://example.com/file.pdf",
"output_path": "/path/to/file.pdf",
"file_size_bytes": 1048576,
"duration_seconds": 2.3
}
}
```
**Limits:** Maximum 100MB file size by default
---
### 3. knowledge_base_search
Search a local knowledge base directory for relevant documents.
**Parameters:**
- `query` (string, required): Search query
- `knowledge_base_path` (string, required): Path to knowledge base directory
- `top_k` (int, default: 5): Number of top results to return
**Returns:**
```json
{
"success": true,
"message": {
"query": "machine learning",
"results": [
{
"file": "docs/ml_basics.md",
"snippet": "...machine learning algorithms...",
"relevance": 12
}
],
"total_found": 3
},
"metadata": {
"knowledge_base": "/path/to/kb",
"top_k": 5
}
}
```
**Supported file types:** .txt, .md, .json
---
## Multimodal Understanding Tools
### 4. webpage_reader
Extract content from web pages including text and links.
**Parameters:**
- `url` (string, required): URL of the webpage
- `extract_text` (bool, default: true): Whether to extract main text content
- `extract_links` (bool, default: false): Whether to extract all links
**Returns:**
```json
{
"success": true,
"message": {
"url": "https://example.com",
"title": "Example Page",
"text": "Page content...",
"text_length": 5000,
"links": []
},
"metadata": {
"url": "https://example.com"
}
}
```
---
### 5. document_reader
Extract content from documents (PDF, DOCX, PPTX).
**Parameters:**
- `file_path` (string, required): Path to document file or URL
- `extract_images` (bool, default: false): Whether to extract images
**Returns:**
```json
{
"success": true,
"message": {
"file_name": "document.pdf",
"file_type": "pdf",
"page_count": 10,
"text": "Document content...",
"text_length": 15000
},
"metadata": {
"file_path": "/path/to/document.pdf",
"file_type": ".pdf"
}
}
```
**Supported formats:** PDF, DOCX, PPTX
---
### 6. image_parser
Parse and analyze image files.
**Parameters:**
- `image_path` (string, required): Path to image file or URL
- `use_llm` (bool, default: true): Use LLM for image understanding
**Returns:**
```json
{
"success": true,
"message": {
"file_name": "image.jpg",
"format": "JPEG",
"mode": "RGB",
"size": [1920, 1080],
"width": 1920,
"height": 1080,
"note": "Full base64 data available for vision API analysis"
},
"metadata": {
"file_path": "/path/to/image.jpg"
}
}
```
**Supported formats:** JPG, PNG, GIF, BMP, TIFF, WEBP
---
### 7. video_parser
Extract metadata and information from video files.
**Parameters:**
- `video_path` (string, required): Path to video file or URL
- `extract_frames` (bool, default: false): Extract sample frames
- `frame_interval` (int, default: 30): Extract one frame every N seconds
**Returns:**
```json
{
"success": true,
"message": {
"file_name": "video.mp4",
"duration_seconds": 120.5,
"fps": 30.0,
"frame_count": 3615,
"resolution": "1920x1080",
"width": 1920,
"height": 1080
},
"metadata": {
"file_path": "/path/to/video.mp4"
}
}
```
**Supported formats:** MP4, AVI, MOV, MKV, WEBM
---
## File System Tools
### 8. file_reader
Read a file and return its contents.
**Parameters:**
- `file_path` (string, required): Path to the file
- `encoding` (string, default: "utf-8"): File encoding
- `max_length` (int, default: 50000): Maximum characters to read
**Returns:**
```json
{
"success": true,
"message": {
"file_path": "/path/to/file.txt",
"content": "File contents...",
"size_bytes": 1024,
"truncated": false,
"encoding": "utf-8"
},
"metadata": {
"file_path": "/path/to/file.txt"
}
}
```
---
### 9. grep
Search for patterns in files using regular expressions.
**Parameters:**
- `pattern` (string, required): Regular expression pattern to search for
- `directory` (string, required): Directory to search in
- `file_pattern` (string, default: "*"): File pattern to match (e.g., "*.py")
- `recursive` (bool, default: true): Search recursively
- `case_sensitive` (bool, default: false): Case-sensitive search
- `max_results` (int, default: 100): Maximum number of results
**Returns:**
```json
{
"success": true,
"message": {
"pattern": "def.*:",
"results": [
{
"file": "src/main.py",
"line_number": 42,
"line": "def my_function():",
"absolute_path": "/full/path/to/src/main.py"
}
],
"total_found": 15,
"truncated": false
},
"metadata": {
"directory": "/path/to/search",
"file_pattern": "*.py",
"recursive": true
}
}
```
---
### 10. text_summarizer
Summarize long text content.
**Parameters:**
- `text` (string, required): Text to summarize
- `max_length` (int, default: 500): Target summary length in characters
- `use_llm` (bool, default: true): Use LLM for better summarization
**Returns:**
```json
{
"success": true,
"message": {
"original_length": 5000,
"summary_length": 500,
"summary": "Summary text...",
"method": "extractive",
"compression_ratio": 0.1
},
"metadata": {
"method": "extractive"
}
}
```
---
## Public Data Source Tools
### 11. weather
Get current weather information for a location.
**Parameters:**
- `location` (string, required): City name, coordinates, or zip code
- `units` (string, default: "metric"): Temperature units (metric/imperial/standard)
**Returns:**
```json
{
"success": true,
"message": {
"location": "London",
"country": "GB",
"temperature": 15.5,
"feels_like": 14.2,
"humidity": 72,
"pressure": 1013,
"weather": "Clouds",
"description": "overcast clouds",
"wind_speed": 5.2,
"units": "metric"
},
"metadata": {
"location": "London",
"units": "metric"
}
}
```
**Requirements:** OpenWeather API key
---
### 12. stock_price
Get current stock price and market information.
**Parameters:**
- `symbol` (string, required): Stock ticker symbol (e.g., "AAPL", "TSLA")
- `interval` (string, default: "1d"): Data interval
**Returns:**
```json
{
"success": true,
"message": {
"symbol": "AAPL",
"currency": "USD",
"current_price": 175.43,
"previous_close": 174.20,
"open": 174.50,
"day_high": 176.00,
"day_low": 173.80,
"volume": 52341000,
"exchange": "NASDAQ"
},
"metadata": {
"symbol": "AAPL"
}
}
```
**Source:** Yahoo Finance (no API key required)
---
### 13. currency_converter
Convert between different currencies.
**Parameters:**
- `amount` (float, required): Amount to convert
- `from_currency` (string, required): Source currency code (e.g., "USD")
- `to_currency` (string, required): Target currency code (e.g., "EUR")
**Returns:**
```json
{
"success": true,
"message": {
"amount": 100.0,
"from_currency": "USD",
"to_currency": "EUR",
"exchange_rate": 0.92,
"converted_amount": 92.0,
"timestamp": "2024-01-15"
},
"metadata": {
"rate": 0.92
}
}
```
**Source:** Exchange Rate API (no API key required)
---
### 14. wikipedia_search
Search Wikipedia and retrieve article summaries.
**Parameters:**
- `query` (string, required): Search query
- `language` (string, default: "en"): Wikipedia language (en, zh, es, etc.)
- `sentences` (int, default: 5): Number of sentences in summary
**Returns:**
```json
{
"success": true,
"message": {
"title": "Artificial Intelligence",
"url": "https://en.wikipedia.org/wiki/Artificial_intelligence",
"summary": "Artificial intelligence (AI) is...",
"language": "en",
"search_results": ["Artificial Intelligence", "AI", "Machine Learning"]
},
"metadata": {
"query": "artificial intelligence",
"language": "en"
}
}
```
---
### 15. arxiv_search
Search ArXiv for academic papers.
**Parameters:**
- `query` (string, required): Search query
- `max_results` (int, default: 5): Maximum number of papers
- `sort_by` (string, default: "relevance"): Sort method (relevance/lastUpdatedDate/submittedDate)
**Returns:**
```json
{
"success": true,
"message": {
"query": "machine learning",
"papers": [
{
"title": "Deep Learning Paper",
"authors": ["John Doe", "Jane Smith"],
"summary": "Paper summary...",
"published": "2024-01-15T00:00:00",
"url": "https://arxiv.org/abs/2401.12345",
"pdf_url": "https://arxiv.org/pdf/2401.12345",
"categories": ["cs.LG", "cs.AI"]
}
],
"count": 5
},
"metadata": {
"query": "machine learning",
"max_results": 5
}
}
```
---
### 16. wayback_search
Search Wayback Machine for archived versions of web pages.
**Parameters:**
- `url` (string, required): URL to search for
- `year` (int, optional): Filter results by specific year
- `limit` (int, default: 10): Maximum number of snapshots
**Returns:**
```json
{
"success": true,
"message": {
"url": "https://example.com",
"snapshots": [
{
"timestamp": "2024-01-15T10:30:00",
"url": "https://web.archive.org/web/20240115103000/https://example.com",
"status_code": "200",
"mime_type": "text/html"
}
],
"count": 10
},
"metadata": {
"url": "https://example.com",
"year": null
}
}
```
---
## Private Data Source Tools
### 17. calendar_events
Get events from Google Calendar.
**Parameters:**
- `start_date` (string, optional): Start date in ISO format (defaults to today)
- `end_date` (string, optional): End date in ISO format (defaults to 7 days from now)
- `calendar_id` (string, default: "primary"): Calendar ID
- `max_results` (int, default: 10): Maximum number of events
**Returns:**
```json
{
"success": true,
"message": {
"events": [
{
"id": "event_id_123",
"summary": "Team Meeting",
"start": "2024-01-15T10:00:00Z",
"end": "2024-01-15T11:00:00Z",
"location": "Conference Room A",
"description": "Weekly team sync",
"attendees": ["john@example.com", "jane@example.com"]
}
],
"count": 5,
"calendar_id": "primary"
},
"metadata": {
"start_date": "2024-01-15T00:00:00Z",
"end_date": "2024-01-22T00:00:00Z"
}
}
```
**Requirements:** Google Calendar API OAuth2 authentication
---
### 18. notion_search
Search Notion workspace or specific database.
**Parameters:**
- `query` (string, required): Search query
- `database_id` (string, optional): Specific database ID to search
- `page_size` (int, default: 10): Results per page
**Returns:**
```json
{
"success": true,
"message": {
"query": "project notes",
"results": [
{
"id": "page_id_123",
"type": "page",
"url": "https://notion.so/page_id_123",
"title": "Project Planning",
"created_time": "2024-01-15T10:00:00Z",
"last_edited_time": "2024-01-16T14:30:00Z"
}
],
"count": 3
},
"metadata": {
"database_id": null
}
}
```
**Requirements:** Notion API key
---
## Error Response Format
All tools return errors in a standardized format:
```json
{
"success": false,
"message": "Error description here",
"metadata": {
"error_type": "specific_error_type"
}
}
```
Common error types:
- `missing_credentials`: API keys not configured
- `api_request_failed`: External API request failed
- `file_not_found`: Specified file doesn't exist
- `invalid_parameters`: Invalid input parameters
- `timeout`: Operation timed out
- `permission_denied`: Insufficient permissions
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""
感知工具 MCP 服务器 —— 统一命令行入口(实验 4-1)。
除了以 MCP stdio 协议对外提供服务(见 src/main.py),本文件提供一个不依赖
MCP 客户端的命令行入口,方便直接列出、调用和演示各类感知工具:
python cli.py list # 按五大类列出全部感知工具
python cli.py info <tool> # 查看某个工具的参数签名
python cli.py run <tool> k=v ... # 直接调用某个工具并打印 JSON 结果
python cli.py demo [--offline] # 运行一个端到端的感知场景演示
工具按《深入理解 AI Agent》第四章「感知工具」的五类场景组织:
搜索、多模态理解、文件系统、公开数据源、私有数据源。
设计说明:
- 每个工具都是异步函数,返回统一的 ActionResponseJSON)。CLI 负责运行事件
循环、解析 JSON 并友好打印。
- 工具按需惰性导入:只有真正调用某个工具时才导入其所在模块,因此在缺少
可选依赖(如 yfinance、opencv、whisper)时,list / info / 离线 demo 仍可正常工作。
"""
import argparse
import asyncio
import importlib
import inspect
import json
import logging
import sys
import tempfile
import typing
from pathlib import Path
SRC_DIR = Path(__file__).parent / "src"
sys.path.insert(0, str(SRC_DIR))
# 五大类的中文标题(与书中实验 4-1 的分类一一对应)
CATEGORIES = {
"search": "搜索",
"multimodal": "多模态理解",
"filesystem": "文件系统",
"public": "公开数据源",
"private": "私有数据源",
}
class Tool(typing.NamedTuple):
"""一个感知工具的注册项。"""
name: str # CLI / MCP 中暴露的工具名
category: str # 所属分类(CATEGORIES 的 key
module: str # src/ 下的模块名
func: str # 模块中的异步函数名
desc: str # 一句话中文描述
online: bool = False # 是否需要联网
note: str = "" # 额外说明(如需要 API Key / 授权)
# ---------------------------------------------------------------------------
# 工具注册表:与 src/main.py 暴露的 MCP 工具保持一致,并补齐 README 中已声明、
# 但此前未在 main.py 注册的三个工具(crypto_price / location_search / poi_search)。
# ---------------------------------------------------------------------------
TOOLS: list[Tool] = [
# ---- 搜索 ----
Tool("web_search", "search", "search_tools", "search_web",
"使用 DuckDuckGo 进行网络搜索(免费,无需 API Key)", online=True),
Tool("knowledge_base_search", "search", "search_tools", "search_knowledge_base",
"在本地知识库目录中做全文检索"),
Tool("download", "search", "search_tools", "download_file",
"从 URL 下载文件到本地(含大小/覆盖保护)", online=True),
Tool("google_search_enhanced", "search", "google_search_enhanced", "google_search_api",
"Google Custom Search,失败时回退 DuckDuckGo", online=True,
note="Google API 需 GOOGLE_API_KEY,未配置则自动回退"),
# ---- 多模态理解 ----
Tool("webpage_reader", "multimodal", "multimodal_tools", "read_webpage",
"抓取并提取网页正文/链接", online=True),
Tool("webpage_read_enhanced", "multimodal", "google_search_enhanced", "read_webpage_content",
"增强版网页正文提取", online=True),
Tool("document_reader", "multimodal", "multimodal_tools", "read_document",
"读取 PDF/DOCX/PPTX 文档内容"),
Tool("pdf_extract", "multimodal", "document_processing_tools", "extract_pdf_text",
"提取 PDF 文本(支持页码范围)"),
Tool("docx_extract", "multimodal", "document_processing_tools", "extract_docx_content",
"提取 WordDOCX)文档内容"),
Tool("pptx_extract", "multimodal", "document_processing_tools", "extract_pptx_content",
"提取 PowerPointPPTX)内容"),
Tool("csv_parse", "multimodal", "document_processing_tools", "extract_csv_content",
"解析 CSV 表格数据"),
Tool("image_parser", "multimodal", "multimodal_tools", "parse_image",
"解析图片(可选 LLM 视觉分析)", note="use_llm 需视觉模型 API"),
Tool("image_ocr", "multimodal", "media_processing_tools", "extract_text_ocr",
"对图片做 OCR 文字识别", note="需安装 tesseract"),
Tool("image_analyze", "multimodal", "media_processing_tools", "analyze_image_ai",
"用视觉模型分析图片内容", note="需视觉模型 API"),
Tool("image_metadata", "multimodal", "media_processing_tools", "get_image_metadata",
"读取图片 EXIF 等元数据"),
Tool("video_parser", "multimodal", "multimodal_tools", "parse_video",
"提取视频元数据/采样帧"),
Tool("video_keyframes", "multimodal", "media_processing_tools", "extract_video_keyframes",
"从视频抽取关键帧"),
Tool("video_analyze", "multimodal", "media_processing_tools", "analyze_video_ai",
"用视觉模型分析视频内容", note="需视觉模型 API"),
Tool("audio_transcribe", "multimodal", "media_processing_tools", "transcribe_audio_whisper",
"用 Whisper 将音频转写为文本", note="需安装 whisper"),
Tool("audio_metadata", "multimodal", "media_processing_tools", "extract_audio_metadata",
"读取音频文件元数据"),
Tool("audio_trim", "multimodal", "media_processing_tools", "trim_audio",
"裁剪音频到指定时间区间"),
Tool("youtube_transcript", "multimodal", "multimodal_tools", "extract_youtube_transcript",
"提取 YouTube 视频字幕", online=True),
Tool("youtube_download", "multimodal", "multimodal_tools", "download_youtube_video",
"下载 YouTube 视频", online=True),
# ---- 文件系统 ----
Tool("file_reader", "filesystem", "filesystem_tools", "read_file",
"读取文件内容(支持编码与截断)"),
Tool("grep", "filesystem", "filesystem_tools", "grep_search",
"在目录中按正则搜索文件内容"),
Tool("text_summarizer", "filesystem", "filesystem_tools", "summarize_text",
"对长文本做摘要(抽取式/截断,占位实现)"),
# ---- 公开数据源 ----
Tool("weather", "public", "public_data_tools", "get_weather",
"查询天气(Open-Meteo,免费无 Key", online=True),
Tool("stock_price", "public", "public_data_tools", "get_stock_price",
"查询股票行情", online=True),
Tool("crypto_price", "public", "public_data_tools", "get_crypto_price",
"查询加密货币价格(CoinGecko,免费无 Key", online=True),
Tool("currency_converter", "public", "public_data_tools", "convert_currency",
"货币汇率换算(免费无 Key", online=True),
Tool("wikipedia_search", "public", "public_data_tools", "search_wikipedia",
"搜索 Wikipedia 并返回摘要", online=True),
Tool("arxiv_search", "public", "public_data_tools", "search_arxiv",
"搜索 ArXiv 学术论文", online=True),
Tool("wayback_search", "public", "public_data_tools", "search_wayback",
"在 Wayback Machine 查历史快照", online=True),
Tool("location_search", "public", "public_data_tools", "search_location",
"地名/地点地理编码(Nominatim,免费无 Key", online=True),
Tool("poi_search", "public", "public_data_tools", "search_poi",
"查询坐标附近的兴趣点(Overpass,免费无 Key", online=True),
Tool("yfinance_quote", "public", "yahoo_finance_tools", "get_stock_quote",
"Yahoo Finance 实时报价", online=True),
Tool("yfinance_historical", "public", "yahoo_finance_tools", "get_historical_data",
"Yahoo Finance 历史行情", online=True),
Tool("yfinance_company_info", "public", "yahoo_finance_tools", "get_company_info",
"Yahoo Finance 公司资料", online=True),
Tool("yfinance_financials", "public", "yahoo_finance_tools", "get_financial_statements",
"Yahoo Finance 财务报表", online=True),
Tool("pubchem_search", "public", "pubchem_tools", "search_compounds",
"在 PubChem 搜索化合物", online=True),
Tool("pubchem_properties", "public", "pubchem_tools", "get_compound_properties",
"获取 PubChem 化合物属性", online=True),
Tool("pubchem_synonyms", "public", "pubchem_tools", "get_compound_synonyms",
"获取 PubChem 化合物别名", online=True),
Tool("pubchem_similar", "public", "pubchem_tools", "search_similar_compounds",
"搜索结构相似的化合物", online=True),
Tool("wiki_article_full", "public", "wiki_enhanced", "get_article_content",
"获取 Wikipedia 条目全文", online=True),
Tool("wiki_article_categories", "public", "wiki_enhanced", "get_article_categories",
"获取 Wikipedia 条目分类", online=True),
Tool("wiki_article_links", "public", "wiki_enhanced", "get_article_links",
"获取 Wikipedia 条目中的链接", online=True),
Tool("wiki_article_history", "public", "wiki_enhanced", "get_article_history",
"获取 Wikipedia 条目历史版本", online=True),
Tool("arxiv_paper_details", "public", "arxiv_enhanced", "get_paper_details",
"获取 ArXiv 论文详情", online=True),
Tool("arxiv_download", "public", "arxiv_enhanced", "download_paper",
"下载 ArXiv 论文 PDF", online=True),
Tool("arxiv_categories", "public", "arxiv_enhanced", "get_arxiv_categories",
"列出 ArXiv 学科分类", online=True),
Tool("wayback_archived_content", "public", "wayback_enhanced", "get_archived_content",
"获取 Wayback 存档页面内容", online=True),
# ---- 私有数据源 ----
Tool("calendar_events", "private", "private_data_tools", "get_calendar_events",
"读取 Google 日历事件", online=True, note="需 Google OAuth 授权"),
Tool("notion_search", "private", "private_data_tools", "search_notion",
"搜索 Notion 工作区", online=True, note="需 NOTION_API_KEY"),
]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
# ---------------------------------------------------------------------------
# 调用辅助
# ---------------------------------------------------------------------------
def _load_callable(tool: Tool):
"""惰性导入并返回工具对应的异步函数。"""
module = importlib.import_module(tool.module)
return getattr(module, tool.func)
def _coerce(value: str, annotation):
"""把命令行传入的字符串按函数注解转换成合适的类型。"""
# 解开 Optional[X] / X | None
origin = typing.get_origin(annotation)
if origin is typing.Union or (origin is not None and str(origin) == "<class 'types.UnionType'>"):
args = [a for a in typing.get_args(annotation) if a is not type(None)]
annotation = args[0] if args else str
origin = typing.get_origin(annotation)
if annotation is bool:
return value.strip().lower() in ("1", "true", "yes", "y", "on")
if annotation is int:
return int(value)
if annotation is float:
return float(value)
if annotation in (list, dict) or origin in (list, dict):
return json.loads(value)
return value
def _parse_kwargs(func, pairs: list[str]) -> dict:
"""把 key=value 列表解析成传给工具函数的关键字参数。"""
sig = inspect.signature(func)
kwargs = {}
for pair in pairs:
if "=" not in pair:
raise ValueError(f"参数必须是 key=value 形式:{pair!r}")
key, _, raw = pair.partition("=")
key = key.strip()
if key not in sig.parameters:
valid = ", ".join(sig.parameters)
raise ValueError(f"未知参数 {key!r},可用参数:{valid}")
kwargs[key] = _coerce(raw, sig.parameters[key].annotation)
return kwargs
def _unwrap(result):
"""工具返回 TextContent(JSON) 或裸 JSON 字符串,统一解析成 dict。"""
text = getattr(result, "text", result)
if isinstance(text, (dict, list)):
return text
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return {"success": True, "message": text, "metadata": {}}
async def _invoke(tool: Tool, kwargs: dict) -> dict:
func = _load_callable(tool)
result = await func(**kwargs)
return _unwrap(result)
# ---------------------------------------------------------------------------
# 子命令实现
# ---------------------------------------------------------------------------
def cmd_list(args) -> int:
print("\n感知工具 MCP 服务器 —— 工具清单(共 {} 个)".format(len(TOOLS)))
print("=" * 72)
cats = [args.category] if args.category else list(CATEGORIES)
for cat in cats:
tools = [t for t in TOOLS if t.category == cat]
if not tools:
continue
print(f"\n{CATEGORIES[cat]}】({len(tools)} 个)")
print("-" * 72)
for t in tools:
flags = []
if t.online:
flags.append("联网")
if t.note:
flags.append(t.note)
tag = f" [{''.join(flags)}]" if flags else ""
print(f" {t.name:<26} {t.desc}{tag}")
print("\n提示:`python cli.py info <tool>` 查看参数;`python cli.py run <tool> k=v` 调用。\n")
return 0
def cmd_info(args) -> int:
tool = TOOLS_BY_NAME.get(args.tool)
if tool is None:
print(f"未找到工具:{args.tool}。用 `python cli.py list` 查看全部。", file=sys.stderr)
return 1
try:
func = _load_callable(tool)
except Exception as e:
print(f"工具 {tool.name} 所在模块导入失败(可能缺少可选依赖):{e}", file=sys.stderr)
return 1
sig = inspect.signature(func)
print(f"\n工具:{tool.name} 分类:{CATEGORIES[tool.category]}")
print(f"描述:{tool.desc}")
print(f"实现:src/{tool.module}.py :: {tool.func}()")
if tool.online:
print("需要联网:是")
if tool.note:
print(f"说明:{tool.note}")
print("\n参数:")
for name, p in sig.parameters.items():
ann = "" if p.annotation is inspect.Parameter.empty else f": {p.annotation}"
default = "" if p.default is inspect.Parameter.empty else f" = {p.default!r}"
print(f" {name}{ann}{default}")
print(f"\n示例:python cli.py run {tool.name} " +
" ".join(f"{n}=..." for n, p in sig.parameters.items()
if p.default is inspect.Parameter.empty) + "\n")
return 0
def cmd_run(args) -> int:
tool = TOOLS_BY_NAME.get(args.tool)
if tool is None:
print(f"未找到工具:{args.tool}。用 `python cli.py list` 查看全部。", file=sys.stderr)
return 1
try:
func = _load_callable(tool)
except Exception as e:
print(f"工具 {tool.name} 所在模块导入失败(可能缺少可选依赖):{e}", file=sys.stderr)
return 1
try:
kwargs = _parse_kwargs(func, args.params)
except Exception as e:
print(f"参数错误:{e}", file=sys.stderr)
return 1
print(f"调用工具 {tool.name} ...", file=sys.stderr)
try:
data = asyncio.run(_invoke(tool, kwargs))
except Exception as e:
print(f"调用失败:{type(e).__name__}: {e}", file=sys.stderr)
return 1
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0 if data.get("success", True) else 2
# ---------------------------------------------------------------------------
# 端到端演示:一个「本地笔记 + 外部资料」研究助手 Agent 的感知流程
# ---------------------------------------------------------------------------
def _header(title: str) -> None:
print("\n" + "" * 72)
print(f"{title}")
print("" * 72)
async def _demo(offline: bool) -> None:
from search_tools import search_web, search_knowledge_base
from filesystem_tools import grep_search, read_file
from public_data_tools import convert_currency, search_wikipedia
from multimodal_tools import read_webpage
# 各工具内部会用 logging.error 打印完整堆栈;演示时抬高阈值,让每步只显示
# CLI 自己组织的干净状态行(真实错误仍以友好提示呈现)。
logging.getLogger().setLevel(logging.CRITICAL)
print("\n" + "=" * 72)
print("感知工具端到端演示")
print("场景:一个研究助手 Agent 需要「先看本地资料、再补充外部信息」")
print(" 本演示串联五类感知工具,展示 Agent 如何『感知世界』" +
("(离线模式:跳过联网步骤)" if offline else ""))
print("=" * 72)
# 准备一个临时本地知识库,避免污染仓库
tmp = Path(tempfile.mkdtemp(prefix="perception_demo_"))
(tmp / "mcp_notes.md").write_text(
"# MCP 调研笔记\n\n"
"Model Context Protocol (MCP) 是一套开放协议,用于在 Agent 与工具/数据源之间\n"
"标准化上下文交换。感知工具(如 web_search、read_file)是 Agent 获取信息的感官。\n"
"关键设计:粒度权衡、输出信息量控制、上下文感知压缩。\n",
encoding="utf-8",
)
(tmp / "budget.md").write_text(
"# 预算\n\n本次调研的云资源预算为 200 USD,需要换算成人民币报销。\n",
encoding="utf-8",
)
# 1) 文件系统感知:在本地代码库里定位实现
_header("[1/5] 文件系统感知:grep 定位 + read_file 精读(离线可用)")
data = _unwrap(await grep_search("ActionResponse", str(SRC_DIR),
file_pattern="*.py", max_results=5))
if data.get("success"):
msg = data["message"]
print(f" grep 'ActionResponse' 命中 {msg['total_found']} 处,示例:")
for hit in msg["results"][:3]:
print(f" - {hit['file']}:{hit['line_number']}")
base_py = _unwrap(await read_file(str(SRC_DIR / "base.py"), max_length=200))
if base_py.get("success"):
head = base_py["message"]["content"].strip().splitlines()[0]
print(f" read_file base.py 首行:{head}")
# 2) 搜索感知:知识库检索(离线)+ 网络搜索(联网)
_header("[2/5] 搜索感知:本地知识库检索(离线)+ 网络搜索(联网)")
kb = _unwrap(await search_knowledge_base("MCP", str(tmp), top_k=3))
if kb.get("success"):
print(f" 知识库检索 'MCP' 命中 {kb['message']['total_found']} 个文件:")
for r in kb["message"]["results"]:
print(f" - {r['file']}(相关度 {r['relevance']}")
if offline:
print(" 网络搜索:已跳过(离线模式)")
else:
try:
web = _unwrap(await search_web("Model Context Protocol", num_results=3))
if web.get("success") and web["message"]["results"]:
print(f" web_search 返回 {web['message']['count']} 条结果,首条:")
top = web["message"]["results"][0]
print(f" - {top['title']}\n {top['url']}")
else:
print(" web_search 未返回结果(可能被限流)")
except Exception as e:
print(f" web_search 失败(需要网络):{e}")
# 3) 公开数据源感知:汇率换算(把预算 200 USD 换成 CNY
_header("[3/5] 公开数据源感知:汇率换算 + Wikipedia 摘要(联网)")
if offline:
print(" 已跳过(离线模式)")
else:
try:
fx = _unwrap(await convert_currency(200, "USD", "CNY"))
if fx.get("success"):
m = fx["message"]
print(f" 预算换算:200 USD ≈ {m['converted_amount']:.2f} CNY"
f"(汇率 {m.get('exchange_rate')}")
except Exception as e:
print(f" 汇率换算失败(需要网络):{e}")
try:
wiki = _unwrap(await search_wikipedia("Model Context Protocol", sentences=2))
if wiki.get("success"):
print(f" Wikipedia{wiki['message']['title']}")
print(f" {wiki['message']['summary'][:120]}...")
else:
print(" Wikipedia 未返回结果(可能被限流),Agent 可改用其它来源")
except Exception as e:
print(f" Wikipedia 查询失败(需要网络):{e}")
# 4) 多模态理解:读取网页正文
_header("[4/5] 多模态理解:抓取网页正文(联网)")
if offline:
print(" 已跳过(离线模式)")
else:
try:
page = _unwrap(await read_webpage("https://example.com", extract_text=True))
if page.get("success"):
m = page["message"]
print(f" 网页标题:{m.get('title')};正文长度:{m.get('text_length', 0)} 字符")
except Exception as e:
print(f" 网页抓取失败(需要网络):{e}")
# 5) 私有数据源:需要授权
_header("[5/5] 私有数据源感知:日历 / Notion(需授权)")
print(" calendar_events 需 Google OAuth 授权,notion_search 需 NOTION_API_KEY。")
print(" 未配置时工具会返回结构化的失败信息,Agent 可据此提示用户去授权。")
print("\n" + "=" * 72)
print("演示完成。要点:感知工具是 Agent 的『感官』——只读、可缓存、可并行;")
print(" 设计关键在于粒度权衡与输出信息量控制(详见第四章)。")
print("=" * 72 + "\n")
# 清理临时知识库
for f in tmp.glob("*"):
f.unlink()
tmp.rmdir()
def cmd_demo(args) -> int:
asyncio.run(_demo(offline=args.offline))
return 0
# ---------------------------------------------------------------------------
# 参数解析
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="cli.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="感知工具 MCP 服务器的命令行入口(实验 4-1)。\n"
"按五类感知场景组织:搜索 / 多模态理解 / 文件系统 / 公开数据源 / 私有数据源。",
epilog="示例:\n"
" python cli.py list 列出全部感知工具\n"
" python cli.py list --category filesystem 只看文件系统类\n"
" python cli.py info weather 查看 weather 的参数\n"
" python cli.py run grep pattern=async directory=src 调用 grep\n"
" python cli.py run currency_converter amount=100 from_currency=USD to_currency=CNY\n"
" python cli.py demo --offline 运行离线端到端演示\n",
)
sub = parser.add_subparsers(dest="command", required=True, metavar="<命令>")
p_list = sub.add_parser("list", help="列出全部感知工具(按五类分组)")
p_list.add_argument("--category", choices=list(CATEGORIES),
help="只列出某一类:" + " / ".join(f"{k}={v}" for k, v in CATEGORIES.items()))
p_list.set_defaults(handler=cmd_list)
p_info = sub.add_parser("info", help="查看某个工具的参数签名与示例")
p_info.add_argument("tool", help="工具名(见 list")
p_info.set_defaults(handler=cmd_info)
p_run = sub.add_parser("run", help="直接调用某个工具并打印 JSON 结果")
p_run.add_argument("tool", help="工具名(见 list")
p_run.add_argument("params", nargs="*", metavar="key=value",
help="以 key=value 形式传入的工具参数")
p_run.set_defaults(handler=cmd_run)
p_demo = sub.add_parser("demo", help="运行端到端感知场景演示")
p_demo.add_argument("--offline", action="store_true",
help="离线模式:只跑不联网的步骤(文件系统 / 本地知识库)")
p_demo.set_defaults(handler=cmd_demo)
return parser
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.WARNING,
format="%(levelname)s: %(message)s")
parser = build_parser()
args = parser.parse_args(argv)
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
# ==============================================================================
# PERCEPTION TOOLS MCP SERVER - ENVIRONMENT CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# FREE APIS (No keys required - enabled by default)
# ------------------------------------------------------------------------------
# Web Search: DuckDuckGo (free, no API key required)
# Weather: Open-Meteo (free, no API key required)
# Stock Prices: Yahoo Finance (free, no API key required)
# Crypto Prices: CoinGecko (free, no API key required)
# Currency: ExchangeRate-API (free, no API key required)
# Location Search: Nominatim/OpenStreetMap (free, no API key required)
# POI Search: Overpass API/OpenStreetMap (free, no API key required)
# Wikipedia: Wikipedia API (free, no API key required)
# ArXiv: ArXiv API (free, no API key required)
# Wayback Machine: Internet Archive (free, no API key required)
# ------------------------------------------------------------------------------
# VISION / LLM (for analyze_image_ai, analyze_video_ai, Whisper API fallback)
# ------------------------------------------------------------------------------
# Direct OpenAI (preferred when set):
# OPENAI_API_KEY=your-openai-api-key
# OPENAI_BASE_URL=https://your-gateway/v1 # optional custom gateway
# PERCEPTION_VISION_MODEL=gpt-5.6-luna # optional model override
# PERCEPTION_VISION_PROVIDER=gemini # select Gemini explicitly
# GEMINI_API_KEY=xxxx # Gemini OpenAI-compatible vision
# Universal OpenRouter fallback: if OPENAI_API_KEY is absent but this is set,
# the vision tools route through OpenRouter (base_url=https://openrouter.ai/api/v1)
# with the model id mapped to provider/model form (gpt-* -> openai/…).
# Note: local Whisper transcription needs OPENAI_API_KEY (OpenRouter has no audio API).
# OPENROUTER_API_KEY=your-openrouter-api-key
# ------------------------------------------------------------------------------
# OPTIONAL APIs (Requires API keys for additional features)
# ------------------------------------------------------------------------------
# Notion API (for Notion integration)
# Get your API key at: https://www.notion.so/my-integrations
# NOTION_API_KEY=your_notion_api_key_here
# Required only for move/copy/delete tools. Mutations accept relative paths and
# are rejected unless this explicit workspace exists. Delete/overwrite are
# reversible quarantine moves beneath the same root.
# PERCEPTION_MUTATION_ROOT=/absolute/path/to/disposable/experiment-workspace
# Google Calendar (uses OAuth2 authentication)
# Run the setup script to authenticate with Google Calendar
# Requires: google-auth-oauthlib, google-auth-httplib2, google-api-python-client
# ------------------------------------------------------------------------------
# LEGACY APIS (No longer needed but kept for backward compatibility)
# ------------------------------------------------------------------------------
# Google Custom Search API (replaced by DuckDuckGo)
# Get API key at: https://developers.google.com/custom-search
# GOOGLE_API_KEY=your_google_api_key_here
# GOOGLE_CSE_ID=your_custom_search_engine_id_here
# OpenWeather API (replaced by Open-Meteo)
# Get API key at: https://openweathermap.org/api
# OPENWEATHER_API_KEY=your_openweather_api_key_here
@@ -0,0 +1,72 @@
{
"experiment": "4-1",
"title": "Perception tools MCP server five-category live campaign",
"authority": "book/chapter4.md:166",
"transport": "mcp-stdio",
"fail_closed": true,
"mutation_root_environment": "PERCEPTION_MUTATION_ROOT",
"categories": {
"search": {
"required_cases": [
"web_search",
"knowledge_base_search",
"download"
]
},
"multimodal": {
"required_cases": [
"webpage_reader",
"document_reader_pdf",
"document_reader_docx",
"document_reader_pptx",
"image_ocr",
"image_analyze",
"audio_transcribe",
"video_parser",
"video_analyze"
],
"credential_blocking_allowed": true
},
"filesystem": {
"required_cases": [
"file_reader",
"grep",
"directory_list",
"filesystem_copy",
"filesystem_move",
"filesystem_delete"
],
"required_safety_cases": [
"reject_parent_traversal",
"reject_absolute_path",
"reject_escaping_symlink"
]
},
"public_data": {
"required_cases": [
"weather",
"yfinance_quote",
"currency_converter",
"wikipedia_search",
"arxiv_search"
]
},
"private_data": {
"required_cases": [
"calendar_events",
"notion_search"
],
"credential_blocking_allowed": true
}
},
"acceptance": {
"catalog_from_real_mcp": true,
"catalog_contains_all_required_tools": true,
"every_success_is_substantive": true,
"filesystem_receipts_include_pre_post_hashes": true,
"filesystem_isolation_probes_are_rejected": true,
"private_sources_require_live_authorized_success": true,
"missing_or_invalid_credentials_never_pass": true,
"manifest_hashes_every_campaign_file": true
}
}
+123
View File
@@ -0,0 +1,123 @@
"""
Quick start script to test the perception tools MCP server.
"""
import asyncio
import json
import logging
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from search_tools import search_web, download_file
from multimodal_tools import read_webpage
from filesystem_tools import read_file, grep_search
from public_data_tools import get_weather, search_wikipedia, convert_currency
logging.basicConfig(level=logging.INFO)
async def test_tools():
"""Test various perception tools."""
print("\n" + "="*80)
print("PERCEPTION TOOLS MCP SERVER - QUICKSTART")
print("="*80)
# Test 1: Web Search
print("\n📝 Test 1: Web Search")
print("-" * 80)
try:
result = await search_web("Python programming", num_results=3)
data = json.loads(result.text)
if data['success']:
print(f"✅ Found {data['message']['count']} results")
if data['message']['results']:
for idx, result_item in enumerate(data['message']['results'], 1):
print(f"\n[{idx}] {result_item['title']}")
print(f" URL: {result_item['url']}")
if result_item.get('snippet'):
print(f" Snippet: {result_item['snippet']}")
else:
print(f"⚠️ Search API not configured: {data['message']}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 2: Wikipedia Search
print("\n📝 Test 2: Wikipedia Search")
print("-" * 80)
try:
result = await search_wikipedia("Artificial Intelligence", sentences=3)
data = json.loads(result.text)
if data['success']:
print(f"✅ Article: {data['message']['title']}")
print(f"Summary: {data['message']['summary'][:200]}...")
except Exception as e:
print(f"❌ Error: {e}")
# Test 3: Currency Conversion
print("\n📝 Test 3: Currency Conversion")
print("-" * 80)
try:
result = await convert_currency(100, "USD", "EUR")
data = json.loads(result.text)
if data['success']:
converted = data['message']['converted_amount']
print(f"✅ 100 USD = {converted:.2f} EUR")
except Exception as e:
print(f"❌ Error: {e}")
# Test 4: Weather
print("\n📝 Test 4: Weather Information")
print("-" * 80)
try:
result = await get_weather("London")
data = json.loads(result.text)
if data['success']:
temp = data['message']['temperature']
desc = data['message']['description']
print(f"✅ London: {temp}°C - {desc}")
else:
print(f"⚠️ Weather API not configured: {data['message']}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 5: Web Page Reading
print("\n📝 Test 5: Web Page Reading")
print("-" * 80)
try:
result = await read_webpage("https://www.example.com", extract_text=True)
data = json.loads(result.text)
if data['success']:
title = data['message']['title']
text_len = data['message'].get('text_length', 0)
print(f"✅ Page: {title}")
print(f"Text length: {text_len} characters")
except Exception as e:
print(f"❌ Error: {e}")
# Test 6: File Operations
print("\n📝 Test 6: File Operations (Reading this script)")
print("-" * 80)
try:
script_path = str(Path(__file__).resolve())
result = await read_file(script_path, max_length=500)
data = json.loads(result.text)
if data['success']:
size = data['message']['size_bytes']
print(f"✅ Read {size} bytes from {Path(script_path).name}")
except Exception as e:
print(f"❌ Error: {e}")
print("\n" + "="*80)
print("QUICKSTART COMPLETE")
print("="*80)
print("\n️ Note: Some tests may fail if API keys are not configured.")
print(" Check env.example and configure your .env file for full functionality.")
print("\n")
if __name__ == "__main__":
asyncio.run(test_tools())
@@ -0,0 +1,49 @@
# Core MCP dependencies. The server and experiment runner use the v2 API and
# the stateless 2026-07-28 protocol; do not silently resolve the legacy v1 SDK.
mcp>=2,<3
pydantic>=2.0.0
python-dotenv>=1.0.0
tiktoken>=0.7.0
# HTTP and web scraping
requests>=2.31.0
httpx>=0.27,<1
beautifulsoup4>=4.12.0
lxml>=5.0.0
# Document processing
PyPDF2>=3.0.0
python-docx>=1.1.0
python-pptx>=0.6.23
Pillow>=10.0.0
# Video processing
opencv-python>=4.8.0
# YouTube
youtube-transcript-api>=0.6.0
yt-dlp>=2023.0.0
# Data sources
wikipedia>=1.4.0
arxiv>=2.0.0
yfinance>=0.2.0
pandas>=2.0.0
waybackpy>=3.0.0
chardet>=5.0.0
# Media processing
openai>=1.0.0
# Optional: Local Whisper (larger download)
# openai-whisper>=20230314
# OCR
# pytesseract>=0.3.0 # Requires tesseract-ocr system package
# Optional: Google Calendar integration
# google-auth-oauthlib>=1.0.0
# google-auth-httplib2>=0.1.0
# google-api-python-client>=2.0.0
# Optional: Notion integration
# notion-client>=2.0.0
@@ -0,0 +1,705 @@
#!/usr/bin/env python3
"""Run Experiment 4-1 through the real perception MCP stdio transport.
The campaign exercises every sub-capability explicitly named by the manuscript.
It creates small local documents/media as deterministic inputs, uses live public
endpoints for network observations, confines mutation tools to a fresh fixture
workspace, and stores credential-safe receipts. Missing private credentials
produce a blocked campaign; they can never satisfy acceptance.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import version as package_version
from pathlib import Path
from typing import Any
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
PROTOCOL_PATH = HERE / "experiment_protocol.json"
SERVER_PATH = HERE / "src" / "main.py"
VALIDATION_ROOT = HERE / "validation" / "experiment_4_1"
CASE_TO_TOOL = {
"web_search": "web_search",
"knowledge_base_search": "knowledge_base_search",
"download": "download",
"webpage_reader": "webpage_reader",
"document_reader_pdf": "document_reader",
"document_reader_docx": "document_reader",
"document_reader_pptx": "document_reader",
"image_ocr": "image_ocr",
"image_analyze": "image_analyze",
"audio_transcribe": "audio_transcribe",
"video_parser": "video_parser",
"video_analyze": "video_analyze",
"file_reader": "file_reader",
"grep": "grep",
"directory_list": "directory_list",
"filesystem_copy": "filesystem_copy",
"filesystem_move": "filesystem_move",
"filesystem_delete": "filesystem_delete",
"reject_parent_traversal": "filesystem_copy",
"reject_absolute_path": "filesystem_delete",
"reject_escaping_symlink": "filesystem_delete",
"weather": "weather",
"yfinance_quote": "yfinance_quote",
"currency_converter": "currency_converter",
"wikipedia_search": "wikipedia_search",
"arxiv_search": "arxiv_search",
"calendar_events": "calendar_events",
"notion_search": "notion_search",
}
PROVENANCE = {
"web_search": {"backend": "duckduckgo-live-search", "origin": "live-api"},
"knowledge_base_search": {"backend": "local-knowledge-files", "origin": "local-filesystem"},
"download": {"backend": "tls-http-download", "origin": "live-api"},
"webpage_reader": {"backend": "tls-http-beautifulsoup", "origin": "live-api"},
"document_reader": {"backend": "format-aware-local-parser", "origin": "local-process"},
"image_ocr": {"backend": "local-tesseract-ocr", "origin": "local-process"},
"image_analyze": {"backend": "configured-vision-api", "origin": "live-api"},
"audio_transcribe": {"backend": "local-whisper-or-openai", "origin": "local-process"},
"video_parser": {"backend": "local-opencv", "origin": "local-process"},
"video_analyze": {"backend": "opencv-and-configured-vision-api", "origin": "live-api"},
"file_reader": {"backend": "local-filesystem", "origin": "local-filesystem"},
"grep": {"backend": "local-regex-filesystem-search", "origin": "local-filesystem"},
"directory_list": {"backend": "local-filesystem", "origin": "local-filesystem"},
"filesystem_copy": {"backend": "workspace-confined-copy", "origin": "local-filesystem"},
"filesystem_move": {"backend": "workspace-confined-rename", "origin": "local-filesystem"},
"filesystem_delete": {"backend": "workspace-confined-quarantine", "origin": "local-filesystem"},
"weather": {"backend": "open-meteo", "origin": "live-api"},
"yfinance_quote": {"backend": "yahoo-finance-yfinance", "origin": "live-api"},
"currency_converter": {"backend": "live-exchange-rate-api", "origin": "live-api"},
"wikipedia_search": {"backend": "mediawiki", "origin": "live-api"},
"arxiv_search": {"backend": "export.arxiv.org", "origin": "live-api"},
"calendar_events": {"backend": "google-calendar-api", "origin": "private-live-api"},
"notion_search": {"backend": "notion-api", "origin": "private-live-api"},
}
SIMULATION_PATTERN = re.compile(r"\b(mock(?:ed)?|placeholder|synthetic|simulat(?:ed|ion))\b", re.I)
MARKER = "PERCEPTION-EXPERIMENT-4-1-VERIFIED"
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
def file_receipt(path: Path) -> dict[str, Any]:
data = path.read_bytes()
return {"path": str(path), "bytes": len(data), "sha256": sha256_bytes(data)}
def command_receipt(command: list[str], *, cwd: Path | None = None) -> dict[str, Any]:
started = time.perf_counter()
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=120)
receipt = {
"executable": command[0],
"arguments": command[1:],
"returncode": result.returncode,
"stdout_sha256": sha256_bytes(result.stdout.encode()),
"stderr_sha256": sha256_bytes(result.stderr.encode()),
"elapsed_seconds": round(time.perf_counter() - started, 3),
}
if result.returncode != 0:
raise RuntimeError(f"fixture command failed: {receipt}")
return receipt
def prepare_fixtures(campaign_dir: Path) -> dict[str, Any]:
"""Create small deterministic inputs with real document/media encoders."""
fixtures = campaign_dir / "fixtures"
knowledge = fixtures / "knowledge"
documents = fixtures / "documents"
media = fixtures / "media"
downloads = fixtures / "downloads"
mutation = fixtures / "mutation_workspace"
for directory in (knowledge, documents, media, downloads, mutation / "nested"):
directory.mkdir(parents=True, exist_ok=True)
note = knowledge / "mcp-notes.md"
note.write_text(
f"# Experiment 4-1\n\n{MARKER}\nThe Model Context Protocol connects agents to perception tools.\n",
encoding="utf-8",
)
(mutation / "seed.txt").write_text(f"{MARKER}\n", encoding="utf-8")
(mutation / "nested" / "entry.txt").write_text("directory browse fixture\n", encoding="utf-8")
outside_witness = fixtures / "outside-witness.txt"
outside_witness.write_text("OUTSIDE-WITNESS-MUST-REMAIN\n", encoding="utf-8")
(mutation / "escape-link").symlink_to(outside_witness)
from reportlab.pdfgen import canvas
pdf = documents / "sample.pdf"
report = canvas.Canvas(str(pdf))
report.drawString(72, 760, f"Experiment 4-1 PDF {MARKER}")
report.save()
from docx import Document
docx = documents / "sample.docx"
document = Document()
document.add_heading("Experiment 4-1 DOCX", level=1)
document.add_paragraph(MARKER)
document.save(docx)
from pptx import Presentation
pptx = documents / "sample.pptx"
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[1])
slide.shapes.title.text = "Experiment 4-1 PPTX"
slide.placeholders[1].text = MARKER
presentation.save(pptx)
from PIL import Image, ImageDraw, ImageFont
image = media / "ocr-source.png"
canvas_image = Image.new("RGB", (1200, 360), "white")
draw = ImageDraw.Draw(canvas_image)
font_candidates = [
Path("/System/Library/Fonts/Supplemental/Arial.ttf"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
]
font_path = next((path for path in font_candidates if path.is_file()), None)
font = ImageFont.truetype(str(font_path), 54) if font_path else ImageFont.load_default()
draw.text((45, 70), "EXPERIMENT 4-1", fill="black", font=font)
draw.text((45, 170), "PERCEPTION TOOLS VERIFIED", fill="black", font=font)
canvas_image.save(image)
audio_aiff = media / "spoken-marker.aiff"
if not shutil.which("say"):
raise RuntimeError("macOS say executable is required for the speech fixture")
say_receipt = command_receipt([
"say", "-v", "Samantha", "-r", "150", "-o", str(audio_aiff),
"Experiment four one. Perception tools verified.",
])
video = media / "visual-marker.mp4"
if not shutil.which("ffmpeg"):
raise RuntimeError("ffmpeg is required for the video fixture")
ffmpeg_receipt = command_receipt([
"ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", str(image),
"-t", "1.5", "-r", "3", "-pix_fmt", "yuv420p", str(video),
])
paths = {
"fixtures": fixtures,
"knowledge": knowledge,
"note": note,
"pdf": pdf,
"docx": docx,
"pptx": pptx,
"image": image,
"audio": audio_aiff,
"video": video,
"downloads": downloads,
"mutation": mutation,
"outside_witness": outside_witness,
}
receipt = {
"marker": MARKER,
"paths": {name: str(path) for name, path in paths.items()},
"files": [
file_receipt(path)
for path in (note, pdf, docx, pptx, image, audio_aiff, video, outside_witness)
],
"generators": {"say": say_receipt, "ffmpeg": ffmpeg_receipt},
}
write_json(campaign_dir / "fixture_receipt.json", receipt)
return paths
def credential_preflight() -> dict[str, Any]:
token_path = Path("~/.perception-tools/google_token.pickle").expanduser()
return {
"secret_values_recorded": False,
"google_calendar": {
"token_file_exists": token_path.is_file(),
"token_file_bytes": token_path.stat().st_size if token_path.is_file() else 0,
"oauth_credentials_sdk_importable": importlib.util.find_spec("google.oauth2.credentials") is not None,
"calendar_sdk_importable": importlib.util.find_spec("googleapiclient.discovery") is not None,
},
"notion": {
"api_key_present": bool(os.environ.get("NOTION_API_KEY")),
"sdk_importable": importlib.util.find_spec("notion_client") is not None,
},
"multimodal": {
"openai_key_present": bool(os.environ.get("OPENAI_API_KEY")),
"openrouter_key_present": bool(os.environ.get("OPENROUTER_API_KEY")),
"gemini_key_present": bool(os.environ.get("GEMINI_API_KEY")),
"dashscope_key_present": bool(os.environ.get("DASHSCOPE_API_KEY")),
"local_whisper_importable": importlib.util.find_spec("whisper") is not None,
"pytesseract_importable": importlib.util.find_spec("pytesseract") is not None,
"tesseract_executable_present": bool(shutil.which("tesseract")),
"ffmpeg_executable_present": bool(shutil.which("ffmpeg")),
},
}
def _parse_text(text: str) -> Any:
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return text
def unwrap_mcp_result(result: Any) -> Any:
structured = getattr(result, "structuredContent", None)
if structured is None:
structured = getattr(result, "structured_content", None)
if structured:
return structured
texts = [getattr(item, "text", None) for item in getattr(result, "content", [])]
texts = [text for text in texts if isinstance(text, str)]
if len(texts) == 1:
return _parse_text(texts[0])
return [_parse_text(text) for text in texts]
def _action_message(payload: Any) -> Any:
if isinstance(payload, dict):
return payload.get("message", payload.get("data"))
return None
def _action_metadata(payload: Any) -> dict[str, Any]:
return payload.get("metadata", {}) if isinstance(payload, dict) else {}
def _declared_simulation_markers(payload: Any) -> list[str]:
"""Scan provenance-like fields, not arbitrary fetched page text."""
markers: list[str] = []
def visit(value: Any, key: str = "") -> None:
if isinstance(value, dict):
for child_key, child in value.items():
if child_key.lower() in {"backend", "provider", "method", "source", "origin"}:
match = SIMULATION_PATTERN.search(str(child))
if match:
markers.append(match.group(0).lower())
if child_key.lower() in {"metadata", "provenance"}:
visit(child, child_key)
visit(payload)
return sorted(set(markers))
def _error_type(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
metadata = payload.get("metadata")
if isinstance(metadata, dict) and metadata.get("error_type"):
return str(metadata["error_type"])
return str(payload.get("error_type")) if payload.get("error_type") else None
def _tool_success(payload: Any, mcp_is_error: bool) -> bool:
return not mcp_is_error and isinstance(payload, dict) and payload.get("success") is True
def substantive_observation(case: str, payload: Any, paths: dict[str, Path]) -> bool:
if not isinstance(payload, dict) or payload.get("success") is not True:
return False
message = _action_message(payload)
metadata = _action_metadata(payload)
if case == "web_search":
return isinstance(message, dict) and bool(message.get("results"))
if case == "knowledge_base_search":
return isinstance(message, dict) and bool(message.get("results"))
if case == "download":
target = paths["downloads"] / "iana-example.html"
return target.is_file() and target.stat().st_size > 100 and metadata.get("file_size_bytes") == target.stat().st_size
if case == "webpage_reader":
return isinstance(message, dict) and bool(message.get("title")) and message.get("text_length", 0) > 50
if case.startswith("document_reader_"):
expected = case.rsplit("_", 1)[1]
return isinstance(message, dict) and message.get("file_type") == expected and message.get("text_length", 0) > 10
if case == "image_ocr":
text = str(message.get("extracted_text", "")) if isinstance(message, dict) else ""
return len(text.strip()) > 10 and "EXPERIMENT" in text.upper()
if case == "image_analyze":
return isinstance(message, dict) and len(str(message.get("analysis", "")).strip()) > 20
if case == "audio_transcribe":
return isinstance(message, dict) and len(str(message.get("transcription", "")).strip()) > 5
if case == "video_parser":
return isinstance(message, dict) and message.get("duration_seconds", 0) > 0 and message.get("frame_count", 0) > 0
if case == "video_analyze":
return isinstance(message, dict) and message.get("frames_analyzed", 0) >= 1 and len(str(message.get("combined_analysis", ""))) > 20
if case == "file_reader":
return isinstance(message, dict) and MARKER in str(message.get("content", ""))
if case == "grep":
return isinstance(message, dict) and message.get("total_found", 0) >= 1
if case == "directory_list":
return isinstance(message, list) and any(row.get("name") == "seed.txt" for row in message if isinstance(row, dict))
if case in {"filesystem_copy", "filesystem_move"}:
return (
isinstance(message, dict)
and message.get("destination_fingerprint") == metadata.get("pre_operation_fingerprint")
and message.get("destination_fingerprint", {}).get("bytes", 0) > 0
)
if case == "filesystem_delete":
return (
isinstance(message, dict)
and message.get("reversible") is True
and message.get("path_exists_after") is False
and message.get("quarantine_fingerprint") == metadata.get("pre_operation_fingerprint")
)
if case == "weather":
return isinstance(message, dict) and message.get("temperature") is not None
if case == "yfinance_quote":
return isinstance(message, dict) and message.get("symbol") == "AAPL" and message.get("current_price") is not None
if case == "currency_converter":
return isinstance(message, dict) and message.get("converted_amount") is not None and message.get("exchange_rate") is not None
if case == "wikipedia_search":
return isinstance(message, dict) and bool(message.get("title")) and bool(message.get("summary"))
if case == "arxiv_search":
return isinstance(message, dict) and bool(message.get("papers"))
if case in {"calendar_events", "notion_search"}:
return isinstance(message, dict) and isinstance(message.get("count"), int)
return False
async def call_case(
client: Client,
case: str,
arguments: dict[str, Any],
paths: dict[str, Path],
) -> dict[str, Any]:
tool = CASE_TO_TOOL[case]
started = time.perf_counter()
try:
result = await client.call_tool(tool, arguments=arguments)
payload = unwrap_mcp_result(result)
mcp_is_error = bool(getattr(result, "isError", False) or getattr(result, "is_error", False))
success = _tool_success(payload, mcp_is_error)
receipt = {
"case": case,
"tool": tool,
"arguments": arguments,
"arguments_sha256": sha256_bytes(canonical_json(arguments).encode()),
"transport": "mcp-stdio",
"mcp_result_is_error": mcp_is_error,
"success": success,
"substantive_observation": substantive_observation(case, payload, paths),
"backend_provenance": PROVENANCE[tool],
"simulation_markers": _declared_simulation_markers(payload),
"error_type": _error_type(payload),
"payload": payload,
"elapsed_seconds": round(time.perf_counter() - started, 3),
}
except Exception as exc:
receipt = {
"case": case,
"tool": tool,
"arguments": arguments,
"arguments_sha256": sha256_bytes(canonical_json(arguments).encode()),
"transport": "mcp-stdio",
"mcp_result_is_error": True,
"success": False,
"substantive_observation": False,
"backend_provenance": PROVENANCE[tool],
"simulation_markers": [],
"error_type": type(exc).__name__,
"payload": {"success": False, "error": str(exc)},
"elapsed_seconds": round(time.perf_counter() - started, 3),
}
return receipt
def credential_blocked(receipt: dict[str, Any]) -> bool:
error_type = str(receipt.get("error_type") or "").lower()
payload_text = canonical_json(receipt.get("payload", {})).lower()
markers = [
"missing_credentials", "missing_library", "not configured", "api key not configured",
"invalid credentials", "unauthorized", "authentication", "insufficient_quota",
"exceeded your current quota", "user not found", "401",
]
return error_type in {"missing_credentials", "missing_library"} or any(marker in payload_text for marker in markers)
def valid_success(receipt: dict[str, Any]) -> bool:
return (
receipt.get("transport") == "mcp-stdio"
and receipt.get("mcp_result_is_error") is False
and receipt.get("success") is True
and receipt.get("substantive_observation") is True
and receipt.get("simulation_markers") == []
and receipt.get("backend_provenance", {}).get("origin") in {
"live-api", "private-live-api", "local-filesystem", "local-process"
}
)
def derive_acceptance(
protocol: dict[str, Any],
catalog: dict[str, Any],
receipts: list[dict[str, Any]],
*,
outside_witness_unchanged: bool,
) -> dict[str, Any]:
by_case = {receipt.get("case"): receipt for receipt in receipts}
required_tools = {CASE_TO_TOOL[case]
for category in protocol["categories"].values()
for case in category.get("required_cases", []) + category.get("required_safety_cases", [])}
category_results: dict[str, Any] = {}
for name, category in protocol["categories"].items():
required = category.get("required_cases", [])
missing = [case for case in required if case not in by_case]
invalid = [case for case in required if case in by_case and not valid_success(by_case[case])]
if not missing and not invalid:
status = "passed"
elif category.get("credential_blocking_allowed") and not missing and invalid and all(
credential_blocked(by_case[case]) for case in invalid
):
status = "blocked"
else:
status = "failed"
category_results[name] = {
"status": status,
"required_cases": required,
"missing_cases": missing,
"invalid_cases": invalid,
}
safety_cases = protocol["categories"]["filesystem"]["required_safety_cases"]
safety_rejected = all(
case in by_case
and by_case[case].get("success") is False
and by_case[case].get("mcp_result_is_error") is False
and by_case[case].get("error_type") == "PermissionError"
for case in safety_cases
) and outside_witness_unchanged
filesystem_hashes = all(
valid_success(by_case[case]) for case in (
"filesystem_copy", "filesystem_move", "filesystem_delete"
)
) if all(case in by_case for case in (
"filesystem_copy", "filesystem_move", "filesystem_delete"
)) else False
if not safety_rejected or not filesystem_hashes:
category_results["filesystem"]["status"] = "failed"
gates = {
"catalog_from_real_mcp": (
catalog.get("transport") == "mcp-stdio"
and catalog.get("tools_list_received") is True
and catalog.get("protocol_version") == "2026-07-28"
and str(catalog.get("mcp_sdk_version", "")).split(".", 1)[0] == "2"
and catalog.get("tool_count") == catalog.get("unique_tool_count")
and catalog.get("tool_count", 0) >= 120
),
"catalog_contains_all_required_tools": required_tools <= set(catalog.get("tool_names", [])),
"search_category_passed": category_results["search"]["status"] == "passed",
"multimodal_category_passed": category_results["multimodal"]["status"] == "passed",
"filesystem_category_passed": category_results["filesystem"]["status"] == "passed",
"public_data_category_passed": category_results["public_data"]["status"] == "passed",
"private_data_category_passed": category_results["private_data"]["status"] == "passed",
"filesystem_pre_post_hashes_verified": filesystem_hashes,
"filesystem_isolation_probes_rejected": safety_rejected,
"all_successes_substantive_and_non_simulated": all(
valid_success(receipt) for receipt in receipts if receipt.get("success") is True
),
"exact_case_set_recorded": set(by_case) == {
case for category in protocol["categories"].values()
for case in category.get("required_cases", []) + category.get("required_safety_cases", [])
},
}
if all(gates.values()):
status = "passed"
elif (
any(category["status"] == "blocked" for category in category_results.values())
and all(category["status"] in {"passed", "blocked"}
for category in category_results.values())
and all(
value for gate, value in gates.items()
if not gate.endswith("_category_passed")
)
):
status = "blocked"
else:
status = "failed"
return {"status": status, "gates": gates, "categories": category_results}
def build_manifest(campaign_dir: Path, summary: dict[str, Any]) -> dict[str, Any]:
files = []
for path in sorted(campaign_dir.rglob("*")):
if path.name == "manifest.json":
continue
if path.is_symlink():
data = os.readlink(path).encode("utf-8")
kind = "symlink-target"
elif path.is_file():
data = path.read_bytes()
kind = "file"
else:
continue
files.append({
"path": str(path.relative_to(campaign_dir)),
"kind": kind,
"bytes": len(data),
"sha256": sha256_bytes(data),
})
return {
"experiment": "4-1",
"campaign_id": summary.get("campaign_id"),
"status": summary.get("status"),
"official_complete": summary.get("status") == "passed",
"generated_at": datetime.now(timezone.utc).isoformat(),
"file_count": len(files),
"files": files,
}
async def run(campaign_id: str | None = None) -> Path:
protocol = json.loads(PROTOCOL_PATH.read_text(encoding="utf-8"))
campaign_id = campaign_id or "real_mcp_" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
campaign_dir = VALIDATION_ROOT / campaign_id
campaign_dir.mkdir(parents=True, exist_ok=False)
write_json(campaign_dir / "protocol.json", protocol)
paths = prepare_fixtures(campaign_dir)
preflight = credential_preflight()
write_json(campaign_dir / "credential_preflight.json", preflight)
outside_before = file_receipt(paths["outside_witness"])
server_env = os.environ.copy()
server_env["PERCEPTION_MUTATION_ROOT"] = str(paths["mutation"])
if server_env.get("DASHSCOPE_API_KEY"):
server_env["PERCEPTION_VISION_PROVIDER"] = "dashscope"
server_env["PERCEPTION_VISION_MODEL"] = "qwen-vl-max"
elif server_env.get("GEMINI_API_KEY"):
server_env["PERCEPTION_VISION_PROVIDER"] = "gemini"
server_env["PERCEPTION_VISION_MODEL"] = "gemini-2.5-flash"
else:
server_env.setdefault("PERCEPTION_VISION_MODEL", "gpt-4o-mini")
parameters = StdioServerParameters(
command=sys.executable,
args=[str(SERVER_PATH)],
env=server_env,
)
receipts: list[dict[str, Any]] = []
async with Client(stdio_client(parameters)) as client:
listed = await client.list_tools()
schemas = [tool.model_dump(by_alias=True, exclude_none=True, mode="json") for tool in listed.tools]
names = [schema["name"] for schema in schemas]
server_info = client.server_info
catalog = {
"transport": "mcp-stdio",
"tools_list_received": True,
"mcp_sdk_version": package_version("mcp"),
"protocol_version": client.protocol_version,
"server_name": server_info.name if server_info else None,
"server_version": server_info.version if server_info else None,
"tool_count": len(names),
"unique_tool_count": len(set(names)),
"tool_names": names,
"schemas_sha256": sha256_bytes(canonical_json(schemas).encode()),
"schemas": schemas,
}
write_json(campaign_dir / "catalog_receipt.json", catalog)
calls = [
("web_search", {"query": "Model Context Protocol official specification", "num_results": 3}),
("knowledge_base_search", {"query": MARKER, "knowledge_base_path": str(paths["knowledge"]), "top_k": 3}),
("download", {"url": "https://www.iana.org/help/example-domains", "output_path": str(paths["downloads"] / "iana-example.html"), "timeout": 60}),
("webpage_reader", {"url": "https://example.com", "extract_text": True, "extract_links": True}),
("document_reader_pdf", {"file_path": str(paths["pdf"])}),
("document_reader_docx", {"file_path": str(paths["docx"])}),
("document_reader_pptx", {"file_path": str(paths["pptx"])}),
("image_ocr", {"image_path": str(paths["image"]), "language": "eng"}),
("image_analyze", {"image_path": str(paths["image"]), "prompt": "Read the prominent text and describe the simple image."}),
("audio_transcribe", {"file_path": str(paths["audio"]), "model_size": "tiny", "language": "en"}),
("video_parser", {"video_path": str(paths["video"]), "extract_frames": False}),
("video_analyze", {"video_path": str(paths["video"]), "num_frames": 1, "prompt": "Read the text shown in this frame."}),
("file_reader", {"file_path": str(paths["note"]), "max_length": 2000}),
("grep", {"pattern": MARKER, "directory": str(paths["knowledge"]), "file_pattern": "*.md", "max_results": 10}),
("directory_list", {"query": str(paths["mutation"]), "options_json": "{\"limit\": 20}"}),
("filesystem_copy", {"source_path": "seed.txt", "destination_path": "copied.txt"}),
("filesystem_move", {"source_path": "copied.txt", "destination_path": "moved.txt"}),
("filesystem_delete", {"path": "moved.txt"}),
("reject_parent_traversal", {"source_path": "seed.txt", "destination_path": "../escaped.txt"}),
("reject_absolute_path", {"path": "/tmp"}),
("reject_escaping_symlink", {"path": "escape-link"}),
("weather", {"location": "Singapore"}),
("yfinance_quote", {"symbol": "AAPL"}),
("currency_converter", {"amount": 10, "from_currency": "USD", "to_currency": "SGD"}),
("wikipedia_search", {"query": "Model Context Protocol", "language": "en", "sentences": 3}),
("arxiv_search", {"query": "agentic artificial intelligence", "max_results": 2, "sort_by": "relevance"}),
("calendar_events", {"calendar_id": "primary", "max_results": 5}),
("notion_search", {"query": "Experiment 4-1", "page_size": 5}),
]
for case, arguments in calls:
receipt = await call_case(client, case, arguments, paths)
receipts.append(receipt)
write_json(campaign_dir / "receipts" / f"{len(receipts):02d}_{case}.json", receipt)
outside_after = file_receipt(paths["outside_witness"])
outside_unchanged = outside_before == outside_after
acceptance = derive_acceptance(
protocol,
catalog,
receipts,
outside_witness_unchanged=outside_unchanged,
)
summary = {
"experiment": "4-1",
"campaign_id": campaign_id,
"status": acceptance["status"],
"official_complete": acceptance["status"] == "passed",
"acceptance": acceptance,
"receipt_count": len(receipts),
"successful_cases": [row["case"] for row in receipts if row["success"]],
"failed_or_blocked_cases": [row["case"] for row in receipts if not row["success"]],
"outside_witness_unchanged": outside_unchanged,
"credential_preflight": preflight,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
write_json(campaign_dir / "summary.json", summary)
write_json(campaign_dir / "manifest.json", build_manifest(campaign_dir, summary))
write_json(VALIDATION_ROOT / "latest.json", {
"experiment": "4-1", "campaign_id": campaign_id,
"status": summary["status"], "official_complete": summary["official_complete"],
"manifest": str((campaign_dir / "manifest.json").relative_to(HERE)),
"manifest_sha256": sha256_bytes((campaign_dir / "manifest.json").read_bytes()),
})
return campaign_dir
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--campaign-id")
args = parser.parse_args()
campaign = asyncio.run(run(args.campaign_id))
summary = json.loads((campaign / "summary.json").read_text(encoding="utf-8"))
print(json.dumps({"campaign": str(campaign), "status": summary["status"]}, indent=2))
return 0 if summary["status"] in {"passed", "blocked"} else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Offline MCP v2 smoke test: start stdio, list tools, and call one tool."""
from __future__ import annotations
import asyncio
import os
import sys
from importlib.metadata import version
from pathlib import Path
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
HERE = Path(__file__).resolve().parent
SERVER = HERE / "src" / "main.py"
PROTOCOL_VERSION = "2026-07-28"
async def smoke_test() -> None:
sdk_version = version("mcp")
if sdk_version.split(".", 1)[0] != "2":
raise RuntimeError(f"Experiment 4-1 requires mcp>=2,<3; found {sdk_version}")
parameters = StdioServerParameters(
command=sys.executable,
args=[str(SERVER)],
env=os.environ.copy(),
)
async with Client(stdio_client(parameters)) as client:
if client.protocol_version != PROTOCOL_VERSION:
raise RuntimeError(
f"expected protocol {PROTOCOL_VERSION}, negotiated {client.protocol_version}"
)
listed = await client.list_tools()
names = {tool.name for tool in listed.tools}
if "file_reader" not in names:
raise RuntimeError("tools/list did not return file_reader")
result = await client.call_tool(
"file_reader",
arguments={"file_path": str(HERE / "requirements.txt"), "max_length": 2_000},
)
if result.is_error:
raise RuntimeError(f"tools/call failed: {result.content!r}")
server_name = client.server_info.name if client.server_info else None
print(
f"MCP smoke test passed: sdk={sdk_version}, "
f"protocol={client.protocol_version}, server={server_name}, tools={len(names)}"
)
if __name__ == "__main__":
asyncio.run(smoke_test())
@@ -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())
)
@@ -0,0 +1,101 @@
"""Regression test: analyze_video_ai must release the VideoCapture even when a
per-frame Vision API call raises mid-loop.
The capture was previously released only on the success path, so a failing
Vision call (network / rate-limit / auth) leaked the native decoder/file handle
until GC. Release now happens in a finally.
"""
import asyncio
import json
import os
import sys
import types
from pathlib import Path
import cv2
import numpy as np
import pytest
SRC = os.path.join(os.path.dirname(__file__), "src")
class _TextContent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
@pytest.fixture
def media_processing_tools(monkeypatch):
"""Import the chapter module with its optional runtime deps stubbed only for
the duration of the test. monkeypatch restores sys.modules / sys.path
afterwards, so we never permanently overwrite a real `mcp` / `dotenv` (or
leave a partial stub in the global cache for other tests to pick up)."""
monkeypatch.syspath_prepend(SRC)
monkeypatch.setitem(
sys.modules, "dotenv", types.SimpleNamespace(load_dotenv=lambda: None)
)
mcp = types.ModuleType("mcp")
mcp_types = types.ModuleType("mcp.types")
mcp_types.TextContent = _TextContent
monkeypatch.setitem(sys.modules, "mcp", mcp)
monkeypatch.setitem(sys.modules, "mcp.types", mcp_types)
# Force a fresh import under the stubs even if another test already imported
# the module; monkeypatch restores the original entry on teardown.
monkeypatch.delitem(sys.modules, "media_processing_tools", raising=False)
import media_processing_tools
return media_processing_tools
class _FakeCapture:
"""Minimal VideoCapture stand-in that yields a few frames and records
whether release() was called."""
def __init__(self):
self.released = False
self._frames = 3
self._i = 0
def get(self, prop):
if prop == cv2.CAP_PROP_FPS:
return 10.0
if prop == cv2.CAP_PROP_FRAME_COUNT:
return float(self._frames)
return 0.0
def isOpened(self):
return True
def read(self):
if self._i < self._frames:
self._i += 1
return True, np.zeros((48, 64, 3), dtype=np.uint8)
return False, None
def release(self):
self.released = True
def test_analyze_video_ai_releases_capture_when_vision_call_raises(
media_processing_tools, monkeypatch
):
mpt = media_processing_tools
fake = _FakeCapture()
monkeypatch.setattr(mpt.cv2, "VideoCapture", lambda _path: fake)
def _boom(**kwargs):
raise RuntimeError("vision backend unavailable")
client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=_boom))
)
monkeypatch.setattr(mpt, "_make_vision_client", lambda: (client, "fake-model"))
# validate_file_path would reject a non-existent path before we reach the
# capture; stub it to pass the path through unchanged.
monkeypatch.setattr(mpt, "validate_file_path", lambda p: Path(p))
result = asyncio.run(mpt.analyze_video_ai("some_clip.mp4", num_frames=2))
payload = json.loads(result.text)
assert payload["success"] is False
assert fake.released is True
@@ -0,0 +1,96 @@
"""Offline regression test for bounded official arXiv API pages."""
from __future__ import annotations
import asyncio
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
SRC = Path(__file__).resolve().parent / "src"
sys.path.insert(0, str(SRC))
import public_data_tools # noqa: E402
def test_search_arxiv_bounds_client_page_size(monkeypatch):
observed: dict[str, object] = {}
class FakeSearch:
def __init__(self, **kwargs):
observed["search"] = kwargs
class FakeClient:
def __init__(self, **kwargs):
observed["client"] = kwargs
def results(self, _search):
return iter([
SimpleNamespace(
title="A real paper",
authors=[SimpleNamespace(name="Author")],
summary="Summary",
published=datetime(2026, 7, 30, tzinfo=timezone.utc),
entry_id="https://arxiv.org/abs/2607.00001",
pdf_url="https://arxiv.org/pdf/2607.00001",
categories=["cs.AI"],
)
])
fake_arxiv = SimpleNamespace(
Search=FakeSearch,
Client=FakeClient,
SortCriterion=SimpleNamespace(
Relevance="relevance",
LastUpdatedDate="lastUpdatedDate",
SubmittedDate="submittedDate",
),
)
monkeypatch.setitem(sys.modules, "arxiv", fake_arxiv)
result = asyncio.run(public_data_tools.search_arxiv(
"transformer", max_results=3, sort_by="submittedDate"
))
payload = json.loads(result.text)
assert observed["search"] == {
"query": "transformer",
"max_results": 3,
"sort_by": "submittedDate",
}
assert observed["client"] == {
"page_size": 3,
"delay_seconds": 3.0,
"num_retries": 3,
}
assert payload["success"] is True
assert payload["message"]["count"] == 1
def test_download_paper_case_insensitive_arxiv_prefix(monkeypatch, tmp_path):
import arxiv_enhanced
class FakeResponse:
content = b"%PDF-1.7\n" + b"0" * 1000
headers = {"content-type": "application/pdf"}
def raise_for_status(self):
pass
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
pass
async def get(self, _url):
return FakeResponse()
monkeypatch.setattr(arxiv_enhanced.httpx, "AsyncClient", FakeClient)
result = asyncio.run(
arxiv_enhanced.download_paper("arXiv:2301.07041", download_dir=str(tmp_path))
)
payload = json.loads(result.text)
assert payload["success"] is True
assert payload["message"]["paper_id"] == "2301.07041"
@@ -0,0 +1,181 @@
"""
Tests for document processing tools.
Uses real files for testing.
"""
import asyncio
import json
import pytest
import tempfile
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent / "src"))
from document_processing_tools import (
extract_pdf_text,
extract_docx_content,
extract_pptx_content,
extract_csv_content
)
class TestPDFExtraction:
"""Tests for PDF extraction."""
@pytest.mark.asyncio
async def test_extract_pdf_basic(self):
"""Test basic PDF extraction."""
# Create a simple PDF for testing
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
pdf_path = Path(tempfile.mktemp(suffix=".pdf"))
# Create PDF
c = canvas.Canvas(str(pdf_path), pagesize=letter)
c.drawString(100, 750, "Hello World")
c.drawString(100, 730, "This is a test PDF")
c.showPage()
c.drawString(100, 750, "Page 2 content")
c.save()
try:
result = await extract_pdf_text(str(pdf_path))
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["file_type"] == "pdf"
assert message["total_pages"] == 2
assert "Hello World" in message["text"]
print(f"✅ PDF extraction successful: {message['total_pages']} pages")
finally:
pdf_path.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_extract_pdf_page_range(self):
"""Test PDF extraction with page range."""
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
pdf_path = Path(tempfile.mktemp(suffix=".pdf"))
c = canvas.Canvas(str(pdf_path), pagesize=letter)
for i in range(1, 6):
c.drawString(100, 750, f"Page {i}")
c.showPage()
c.save()
try:
result = await extract_pdf_text(str(pdf_path), page_range="1-3")
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["pages_extracted"] == 3
print(f"✅ PDF page range: extracted {message['pages_extracted']} pages")
finally:
pdf_path.unlink(missing_ok=True)
class TestDOCXExtraction:
"""Tests for DOCX extraction."""
@pytest.mark.asyncio
async def test_extract_docx(self):
"""Test DOCX extraction."""
from docx import Document
docx_path = Path(tempfile.mktemp(suffix=".docx"))
# Create DOCX
doc = Document()
doc.add_paragraph("First paragraph")
doc.add_paragraph("Second paragraph")
doc.save(docx_path)
try:
result = await extract_docx_content(str(docx_path))
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["file_type"] == "docx"
assert message["paragraphs"] >= 2
assert "First paragraph" in message["text"]
print(f"✅ DOCX extraction: {message['paragraphs']} paragraphs")
finally:
docx_path.unlink(missing_ok=True)
class TestPPTXExtraction:
"""Tests for PPTX extraction."""
@pytest.mark.asyncio
async def test_extract_pptx(self):
"""Test PPTX extraction."""
from pptx import Presentation
pptx_path = Path(tempfile.mktemp(suffix=".pptx"))
# Create PPTX
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "Test Slide"
prs.save(pptx_path)
try:
result = await extract_pptx_content(str(pptx_path))
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["file_type"] == "pptx"
assert message["total_slides"] >= 1
print(f"✅ PPTX extraction: {message['total_slides']} slides")
finally:
pptx_path.unlink(missing_ok=True)
class TestCSVExtraction:
"""Tests for CSV extraction."""
@pytest.mark.asyncio
async def test_extract_csv(self):
"""Test CSV extraction."""
csv_path = Path(tempfile.mktemp(suffix=".csv"))
# Create CSV
csv_content = """name,age,city
Alice,30,New York
Bob,25,Los Angeles
Charlie,35,Chicago"""
csv_path.write_text(csv_content)
try:
result = await extract_csv_content(str(csv_path))
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["file_type"] == "csv"
assert message["rows"] == 3
assert message["columns"] == 3
assert "name" in message["column_names"]
print(f"✅ CSV extraction: {message['rows']} rows, {message['columns']} columns")
finally:
csv_path.unlink(missing_ok=True)
if __name__ == "__main__":
print("=" * 70)
print("Running Document Processing Tools Tests")
print("=" * 70)
print()
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,60 @@
"""Offline contract checks for the real-backed 126-tool perception catalog."""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
import tiktoken
SRC = Path(__file__).resolve().parent / "src"
sys.path.insert(0, str(SRC))
import expanded_catalog # noqa: E402
import main # noqa: E402
def _schemas() -> list[dict]:
tools = asyncio.run(main.mcp.list_tools())
return [tool.model_dump(by_alias=True, exclude_none=True, mode="json")
for tool in tools]
def test_catalog_has_126_unique_complete_schemas_over_50k_tokens():
schemas = _schemas()
names = {schema["name"] for schema in schemas}
rendered = "\n".join(json.dumps(schema, ensure_ascii=False, indent=2)
for schema in schemas)
assert len(schemas) == len(names) == 126
assert len(expanded_catalog.EXPANDED_SPECS) == 70
assert len(expanded_catalog.EXISTING_TOOL_CONTRACTS) == 56
assert len(tiktoken.get_encoding("o200k_base").encode(rendered)) > 50_000
assert {
"web_search", "code_interpreter", "yfinance_quote", "search_news",
"arxiv_search", "arxiv_download", "github_list_contributors",
} <= names
assert all("success" in schema["description"].lower()
and "failure" in schema["description"].lower()
for schema in schemas)
def test_expanded_parameter_descriptions_are_tool_specific():
schemas = {schema["name"]: schema for schema in _schemas()}
for spec in expanded_catalog.EXPANDED_SPECS:
properties = schemas[spec.name]["inputSchema"]["properties"]
assert spec.name in properties["query"]["description"]
assert spec.name in properties["options_json"]["description"]
def test_code_interpreter_nonzero_exit_fails_closed():
spec = next(spec for spec in expanded_catalog.EXPANDED_SPECS
if spec.name == "code_interpreter")
receipt = asyncio.run(expanded_catalog.execute_expanded_tool(
spec, 'raise RuntimeError("expected")', '{"timeout": 5}'
))
assert receipt["success"] is False
assert receipt["error_type"] == "ProcessExecutionError"
assert receipt["data"]["returncode"] != 0
assert "RuntimeError" in receipt["data"]["stderr"]
@@ -0,0 +1,147 @@
"""Offline acceptance checks for the exact Experiment 4-1 campaign."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
RUNNER = HERE / "run_experiment_4_1.py"
SPEC = importlib.util.spec_from_file_location("experiment_4_1_runner", RUNNER)
runner = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = runner
SPEC.loader.exec_module(runner)
def _protocol() -> dict:
return json.loads((HERE / "experiment_protocol.json").read_text(encoding="utf-8"))
def _receipt(case: str, *, success: bool = True) -> dict:
return {
"case": case,
"tool": runner.CASE_TO_TOOL[case],
"transport": "mcp-stdio",
"mcp_result_is_error": False,
"success": success,
"substantive_observation": success,
"backend_provenance": runner.PROVENANCE[runner.CASE_TO_TOOL[case]],
"simulation_markers": [],
"error_type": None,
"payload": {"success": success},
}
def _all_receipts() -> list[dict]:
protocol = _protocol()
return [
_receipt(case)
for category in protocol["categories"].values()
for case in category.get("required_cases", []) + category.get("required_safety_cases", [])
]
def _catalog() -> dict:
names = set(runner.CASE_TO_TOOL.values())
names.update(f"extra_{index}" for index in range(120))
return {
"transport": "mcp-stdio",
"tools_list_received": True,
"mcp_sdk_version": "2.0.0",
"protocol_version": "2026-07-28",
"tool_count": len(names),
"unique_tool_count": len(names),
"tool_names": sorted(names),
}
def test_catalog_gate_requires_v2_sdk_and_current_protocol():
catalog = _catalog()
assert runner.derive_acceptance(
_protocol(), catalog, _all_receipts(), outside_witness_unchanged=True
)["gates"]["catalog_from_real_mcp"]
catalog["protocol_version"] = "2025-11-25"
assert not runner.derive_acceptance(
_protocol(), catalog, _all_receipts(), outside_witness_unchanged=True
)["gates"]["catalog_from_real_mcp"]
catalog.update(protocol_version="2026-07-28", mcp_sdk_version="1.29.0")
assert not runner.derive_acceptance(
_protocol(), catalog, _all_receipts(), outside_witness_unchanged=True
)["gates"]["catalog_from_real_mcp"]
def test_protocol_covers_every_manuscript_category_and_mutation():
protocol = _protocol()
assert list(protocol["categories"]) == [
"search", "multimodal", "filesystem", "public_data", "private_data"
]
assert {"filesystem_move", "filesystem_copy", "filesystem_delete"} <= set(
protocol["categories"]["filesystem"]["required_cases"]
)
assert {"calendar_events", "notion_search"} == set(
protocol["categories"]["private_data"]["required_cases"]
)
def test_acceptance_fails_closed_when_receipts_are_missing():
result = runner.derive_acceptance(
_protocol(), _catalog(), [], outside_witness_unchanged=True
)
assert result["status"] == "failed"
assert not result["gates"]["exact_case_set_recorded"]
assert not result["gates"]["private_data_category_passed"]
def test_private_credential_failure_is_blocked_and_never_passed():
receipts = _all_receipts()
for receipt in receipts:
if receipt["case"] in {"calendar_events", "notion_search"}:
receipt.update({
"success": False,
"substantive_observation": False,
"error_type": "missing_credentials",
"payload": {
"success": False,
"metadata": {"error_type": "missing_credentials"},
},
})
if receipt["case"].startswith("reject_"):
receipt.update({
"success": False,
"substantive_observation": False,
"error_type": "PermissionError",
"payload": {"success": False},
})
result = runner.derive_acceptance(
_protocol(), _catalog(), receipts, outside_witness_unchanged=True
)
assert result["status"] == "blocked"
assert result["categories"]["private_data"]["status"] == "blocked"
assert result["gates"]["private_data_category_passed"] is False
def test_mock_marker_invalidates_an_apparent_success():
receipt = _receipt("weather")
receipt["simulation_markers"] = ["mock"]
assert runner.valid_success(receipt) is False
def test_isolation_probe_must_preserve_outside_witness():
receipts = _all_receipts()
for receipt in receipts:
if receipt["case"].startswith("reject_"):
receipt.update({
"success": False,
"substantive_observation": False,
"error_type": "PermissionError",
})
result = runner.derive_acceptance(
_protocol(), _catalog(), receipts, outside_witness_unchanged=False
)
assert result["status"] == "failed"
assert result["gates"]["filesystem_isolation_probes_rejected"] is False
@@ -0,0 +1,76 @@
"""Safety and receipt checks for Experiment 4-1 filesystem mutations."""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
import pytest
SRC = Path(__file__).resolve().parent / "src"
sys.path.insert(0, str(SRC))
from filesystem_tools import copy_path, delete_path, move_path # noqa: E402
def _unwrap(result) -> dict:
return json.loads(result.text)
def test_move_copy_delete_are_real_verified_and_reversible(tmp_path, monkeypatch):
monkeypatch.setenv("PERCEPTION_MUTATION_ROOT", str(tmp_path))
(tmp_path / "input.txt").write_text("experiment 4-1\n", encoding="utf-8")
copied = _unwrap(asyncio.run(copy_path("input.txt", "copied.txt")))
assert copied["success"] is True
assert (tmp_path / "input.txt").is_file()
assert copied["metadata"]["pre_operation_fingerprint"] == copied["message"][
"destination_fingerprint"
]
moved = _unwrap(asyncio.run(move_path("copied.txt", "moved.txt")))
assert moved["success"] is True
assert not (tmp_path / "copied.txt").exists()
assert (tmp_path / "moved.txt").is_file()
deleted = _unwrap(asyncio.run(delete_path("moved.txt")))
assert deleted["success"] is True
assert deleted["message"]["reversible"] is True
assert not (tmp_path / "moved.txt").exists()
quarantined = tmp_path / deleted["message"]["quarantine_path"]
assert quarantined.read_text(encoding="utf-8") == "experiment 4-1\n"
assert deleted["metadata"]["pre_operation_fingerprint"] == deleted["message"][
"quarantine_fingerprint"
]
@pytest.mark.parametrize("candidate", ["../outside.txt", "/tmp/outside.txt", "."])
def test_mutations_reject_traversal_absolute_paths_and_root(candidate, tmp_path, monkeypatch):
monkeypatch.setenv("PERCEPTION_MUTATION_ROOT", str(tmp_path))
(tmp_path / "safe.txt").write_text("safe", encoding="utf-8")
receipt = _unwrap(asyncio.run(copy_path("safe.txt", candidate)))
assert receipt["success"] is False
assert receipt["metadata"]["error_type"] in {"PermissionError", "ValueError"}
def test_mutations_reject_symlinks_that_escape_root(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("do not touch", encoding="utf-8")
(workspace / "escape").symlink_to(outside)
monkeypatch.setenv("PERCEPTION_MUTATION_ROOT", str(workspace))
receipt = _unwrap(asyncio.run(delete_path("escape")))
assert receipt["success"] is False
assert receipt["metadata"]["error_type"] == "PermissionError"
assert outside.read_text(encoding="utf-8") == "do not touch"
def test_mutations_fail_closed_without_explicit_root(tmp_path, monkeypatch):
monkeypatch.delenv("PERCEPTION_MUTATION_ROOT", raising=False)
receipt = _unwrap(asyncio.run(delete_path("anything")))
assert receipt["success"] is False
assert receipt["metadata"]["error_type"] == "PermissionError"
+75
View File
@@ -0,0 +1,75 @@
"""
Test script to verify all imports and basic module loading.
"""
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
print("Testing imports...")
try:
print("\n✓ Importing base module...")
from base import ActionResponse, DocumentMetadata, is_url, validate_file_path
print("✓ Importing search_tools module...")
from search_tools import search_web, download_file, search_knowledge_base
print("✓ Importing multimodal_tools module...")
from multimodal_tools import read_webpage, read_document, parse_image, parse_video
print("✓ Importing filesystem_tools module...")
from filesystem_tools import read_file, grep_search, summarize_text
print("✓ Importing public_data_tools module...")
from public_data_tools import (
get_weather, get_stock_price, convert_currency,
search_wikipedia, search_arxiv, search_wayback
)
print("✓ Importing private_data_tools module...")
from private_data_tools import get_calendar_events, search_notion
print("✓ Importing main module...")
from main import mcp
print("\n" + "="*80)
print("✅ All imports successful!")
print("="*80)
# Test basic functionality
print("\nTesting basic functionality...")
# Test ActionResponse
response = ActionResponse(
success=True,
message="Test message",
metadata={"test": "value"}
)
assert response.success == True
print("✓ ActionResponse working")
# Test is_url
assert is_url("https://example.com") == True
assert is_url("/path/to/file") == False
print("✓ is_url working")
print("\n" + "="*80)
print("✅ All tests passed!")
print("="*80)
print("\n️ The MCP server is ready to use.")
print(" Run 'python src/main.py' to start the server.")
print(" Run 'python quickstart.py' to test various tools.")
except ImportError as e:
print(f"\n❌ Import error: {e}")
print("\n️ You may need to install dependencies:")
print(" pip install -r requirements.txt")
sys.exit(1)
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+129
View File
@@ -0,0 +1,129 @@
"""
Test script for new perception tools: crypto prices, location search, and POI search.
"""
import asyncio
import json
import logging
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from public_data_tools import get_crypto_price, search_location, search_poi
logging.basicConfig(level=logging.INFO)
async def test_new_tools():
"""Test the new perception tools."""
print("\n" + "="*80)
print("NEW PERCEPTION TOOLS - TEST")
print("="*80)
# Test 1: Crypto Price
print("\n📝 Test 1: Cryptocurrency Price (Bitcoin)")
print("-" * 80)
try:
result = await get_crypto_price("btc", "usd")
data = json.loads(result.text)
if data['success']:
msg = data['message']
print(f"{msg['symbol']}: ${msg['current_price']:,.2f} USD")
print(f" 24h Change: {msg['price_change_24h_percent']:.2f}%")
print(f" Market Cap: ${msg['market_cap']:,.0f}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 2: Crypto Price - Ethereum
print("\n📝 Test 2: Cryptocurrency Price (Ethereum)")
print("-" * 80)
try:
result = await get_crypto_price("eth", "eur")
data = json.loads(result.text)
if data['success']:
msg = data['message']
print(f"{msg['symbol']}: €{msg['current_price']:,.2f} EUR")
print(f" 24h Volume: €{msg['volume_24h']:,.0f}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 3: Location Search
print("\n📝 Test 3: Location Search (Eiffel Tower)")
print("-" * 80)
try:
result = await search_location("Eiffel Tower", limit=3)
data = json.loads(result.text)
if data['success']:
locations = data['message']['locations']
print(f"✅ Found {len(locations)} locations:")
for loc in locations[:2]:
print(f" - {loc['display_name']}")
print(f" Coordinates: ({loc['latitude']}, {loc['longitude']})")
except Exception as e:
print(f"❌ Error: {e}")
# Test 4: Location Search with Country Filter
print("\n📝 Test 4: Location Search (Paris, France only)")
print("-" * 80)
try:
result = await search_location("Paris", limit=3, country_code="fr")
data = json.loads(result.text)
if data['success']:
locations = data['message']['locations']
print(f"✅ Found {len(locations)} locations in France:")
for loc in locations[:2]:
print(f" - {loc['display_name']}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 5: POI Search (Restaurants near Eiffel Tower)
print("\n📝 Test 5: POI Search (Restaurants near Eiffel Tower)")
print("-" * 80)
try:
# Eiffel Tower coordinates
latitude = 48.8584
longitude = 2.2945
result = await search_poi("restaurant", latitude, longitude, radius=500, limit=5)
data = json.loads(result.text)
if data['success']:
pois = data['message']['pois']
print(f"✅ Found {len(pois)} restaurants:")
for poi in pois[:3]:
print(f" - {poi['name']}")
if poi.get('cuisine'):
print(f" Cuisine: {poi['cuisine']}")
except Exception as e:
print(f"❌ Error: {e}")
# Test 6: POI Search (Coffee shops in San Francisco)
print("\n📝 Test 6: POI Search (Coffee shops in San Francisco)")
print("-" * 80)
try:
# San Francisco downtown coordinates
latitude = 37.7749
longitude = -122.4194
result = await search_poi("cafe", latitude, longitude, radius=1000, limit=5)
data = json.loads(result.text)
if data['success']:
pois = data['message']['pois']
print(f"✅ Found {len(pois)} cafes:")
for poi in pois[:3]:
print(f" - {poi['name']}")
if poi.get('opening_hours'):
print(f" Hours: {poi['opening_hours']}")
except Exception as e:
print(f"❌ Error: {e}")
print("\n" + "="*80)
print("TEST COMPLETE")
print("="*80)
print("\n️ All tests use free, open APIs - no API keys required!")
print("\n")
if __name__ == "__main__":
asyncio.run(test_new_tools())
@@ -0,0 +1,33 @@
"""Trailing commas in page_range must not crash parse_page_range."""
import importlib.util
from pathlib import Path
_SRC = Path(__file__).resolve().parent / "src" / "document_processing_tools.py"
_spec = importlib.util.spec_from_file_location("document_processing_tools_page_range", _SRC)
_mod = importlib.util.module_from_spec(_spec)
# Load only parse_page_range without heavy optional deps (PyPDF2, etc.).
source = _SRC.read_text(encoding="utf-8")
start = source.index("def parse_page_range")
ns = {}
exec(compile(source[start:], str(_SRC), "exec"), ns)
parse_page_range = ns["parse_page_range"]
def test_trailing_comma_does_not_raise():
assert parse_page_range("1,3,", 10) == [0, 2]
def test_duplicate_comma_does_not_raise():
assert parse_page_range("1,,3", 10) == [0, 2]
def test_leading_comma_does_not_raise():
assert parse_page_range(",1,5", 10) == [0, 4]
def test_normal_list_unchanged():
assert parse_page_range("1,3,5", 10) == [0, 2, 4]
def test_range_with_trailing_comma():
assert parse_page_range("1-3,", 10) == [0, 1, 2]
@@ -0,0 +1,52 @@
"""Deterministic regressions for PubChem asynchronous ListKey handling."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from pubchem_tools import PubChemClient
class _Response:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
self.text = str(payload)
def json(self):
return self._payload
def test_listkey_job_fault_resubmits_original_query(monkeypatch):
client = PubChemClient()
original = (
"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/formula/C9H8O4/"
"property/Title,MolecularFormula/JSON"
)
calls = []
responses = iter([
_Response(202, {"Waiting": {"ListKey": "first"}}),
_Response(500, {"Fault": {"Code": "PUGREST.Unknown"}}),
_Response(202, {"Waiting": {"ListKey": "second"}}),
_Response(200, {"PropertyTable": {"Properties": [{"CID": 2244}]}}),
])
def fake_get(url, params=None, timeout=None):
calls.append(url)
return next(responses)
monkeypatch.setattr(client.session, "get", fake_get)
monkeypatch.setattr(client, "_rate_limit", lambda: 0.0)
monkeypatch.setattr("pubchem_tools.time.sleep", lambda _seconds: None)
payload, latency = client.make_request(original)
assert payload["PropertyTable"]["Properties"][0]["CID"] == 2244
assert latency >= 0
assert calls == [
original,
original.replace("formula/C9H8O4", "listkey/first"),
original,
original.replace("formula/C9H8O4", "listkey/second"),
]
@@ -0,0 +1,327 @@
"""
Real API tests for PubChem tools.
These tests make actual API calls to PubChem to verify functionality.
"""
import asyncio
import json
import pytest
from pathlib import Path
import sys
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from pubchem_tools import (
search_compounds,
get_compound_properties,
get_compound_synonyms,
search_similar_compounds
)
class TestPubChemSearch:
"""Tests for compound search functionality."""
@pytest.mark.asyncio
async def test_search_by_name(self):
"""Test searching compounds by name."""
result = await search_compounds(
query="aspirin",
search_type="name",
max_results=5
)
# Parse result
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["query"] == "aspirin"
assert message["search_type"] == "name"
assert len(message["compounds"]) > 0
# Check first compound has expected fields
compound = message["compounds"][0]
assert compound["cid"] is not None
assert compound["name"] is not None
assert compound["molecular_formula"] is not None
assert compound["molecular_weight"] is not None
print(f"✅ Found {len(message['compounds'])} compounds for 'aspirin'")
print(f" First: {compound['name']} (CID: {compound['cid']})")
@pytest.mark.asyncio
async def test_search_by_cid(self):
"""Test searching compound by CID."""
result = await search_compounds(
query="2244", # Aspirin CID
search_type="cid",
max_results=1
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert len(message["compounds"]) == 1
compound = message["compounds"][0]
assert compound["cid"] == 2244
assert "aspirin" in compound["name"].lower() or "acetylsalicylic" in compound["name"].lower()
print(f"✅ Found compound by CID: {compound['name']}")
@pytest.mark.asyncio
async def test_search_by_formula(self):
"""Test searching compounds by molecular formula."""
result = await search_compounds(
query="C9H8O4", # Aspirin formula
search_type="formula",
max_results=10
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert len(message["compounds"]) > 0
# Check all compounds have the correct formula
for compound in message["compounds"]:
assert compound["molecular_formula"] == "C9H8O4"
print(f"✅ Found {len(message['compounds'])} compounds with formula C9H8O4")
@pytest.mark.asyncio
async def test_search_by_smiles(self):
"""Test searching compound by SMILES."""
result = await search_compounds(
query="CC(=O)OC1=CC=CC=C1C(=O)O", # Aspirin SMILES
search_type="smiles",
max_results=1
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert len(message["compounds"]) > 0
print(f"✅ Found compound by SMILES")
@pytest.mark.asyncio
async def test_search_invalid_query(self):
"""Test searching with invalid query."""
result = await search_compounds(
query="",
search_type="name",
max_results=5
)
data = json.loads(result.text)
assert data["success"] is False
assert "required" in data["message"].lower()
print("✅ Correctly handled invalid query")
@pytest.mark.asyncio
async def test_search_not_found(self):
"""Test searching for non-existent compound."""
result = await search_compounds(
query="xyzabc123notarealcompound999",
search_type="name",
max_results=5
)
data = json.loads(result.text)
# Should fail or return empty results
if data["success"]:
assert data["message"]["count"] == 0
print("✅ Handled non-existent compound search")
class TestPubChemProperties:
"""Tests for compound properties functionality."""
@pytest.mark.asyncio
async def test_get_properties(self):
"""Test getting compound properties."""
result = await get_compound_properties(
cid=2244, # Aspirin
properties=["MolecularWeight", "MolecularFormula", "XLogP", "TPSA"]
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["cid"] == 2244
props = message["properties"]
assert "MolecularWeight" in props
assert "MolecularFormula" in props
assert props["MolecularFormula"] == "C9H8O4"
print(f"✅ Retrieved properties for aspirin:")
print(f" Formula: {props['MolecularFormula']}")
print(f" Weight: {props['MolecularWeight']}")
@pytest.mark.asyncio
async def test_get_default_properties(self):
"""Test getting default properties."""
result = await get_compound_properties(
cid=2244 # Aspirin
)
data = json.loads(result.text)
assert data["success"] is True
props = data["message"]["properties"]
# Should have default properties
assert len(props) > 0
assert "MolecularWeight" in props
print(f"✅ Retrieved {len(props)} default properties")
@pytest.mark.asyncio
async def test_get_properties_invalid_cid(self):
"""Test getting properties with invalid CID."""
result = await get_compound_properties(
cid=-1
)
data = json.loads(result.text)
assert data["success"] is False
print("✅ Correctly handled invalid CID")
class TestPubChemSynonyms:
"""Tests for compound synonyms functionality."""
@pytest.mark.asyncio
async def test_get_synonyms(self):
"""Test getting compound synonyms."""
result = await get_compound_synonyms(
cid=2244, # Aspirin
max_synonyms=10
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["cid"] == 2244
assert len(message["synonyms"]) > 0
# Check that common names are included
synonyms_lower = [s.lower() for s in message["synonyms"]]
assert any("aspirin" in s for s in synonyms_lower)
print(f"✅ Retrieved {len(message['synonyms'])} synonyms:")
print(f" Examples: {', '.join(message['synonyms'][:3])}")
@pytest.mark.asyncio
async def test_get_synonyms_limit(self):
"""Test synonym count limit."""
result = await get_compound_synonyms(
cid=2244,
max_synonyms=5
)
data = json.loads(result.text)
assert data["success"] is True
synonyms = data["message"]["synonyms"]
assert len(synonyms) <= 5
print(f"✅ Correctly limited synonyms to {len(synonyms)}")
class TestPubChemSimilarity:
"""Tests for similar compounds search."""
@pytest.mark.asyncio
async def test_search_similar(self):
"""Test searching similar compounds."""
result = await search_similar_compounds(
cid=2244, # Aspirin
similarity_threshold=0.9,
max_results=5
)
data = json.loads(result.text)
assert data["success"] is True
message = data["message"]
assert message["reference_cid"] == 2244
assert message["similarity_threshold"] == 0.9
similar = message["similar_compounds"]
# Should find at least some similar compounds
if len(similar) > 0:
compound = similar[0]
assert compound["cid"] is not None
assert compound["cid"] != 2244 # Should not include itself
assert compound["name"] is not None
print(f"✅ Found {len(similar)} similar compounds:")
print(f" Example: {compound['name']} (CID: {compound['cid']})")
else:
print("✅ Search completed (no similar compounds at 0.9 threshold)")
@pytest.mark.asyncio
async def test_search_similar_lower_threshold(self):
"""Test searching similar compounds with lower threshold."""
result = await search_similar_compounds(
cid=2244,
similarity_threshold=0.7, # Lower threshold
max_results=10
)
data = json.loads(result.text)
assert data["success"] is True
similar = data["message"]["similar_compounds"]
# With lower threshold, should find more compounds
print(f"✅ Found {len(similar)} compounds at 0.7 similarity")
class TestPubChemRateLimit:
"""Tests for rate limiting functionality."""
@pytest.mark.asyncio
async def test_multiple_requests(self):
"""Test that multiple requests are rate-limited."""
import time
start_time = time.time()
# Make 5 requests in quick succession
for i in range(5):
result = await search_compounds(
query="aspirin",
search_type="name",
max_results=1
)
data = json.loads(result.text)
assert data["success"] is True
elapsed = time.time() - start_time
# Should take at least 0.8 seconds (5 requests * 0.2s delay - 0.2s for first)
assert elapsed >= 0.8
print(f"✅ Rate limiting working: {elapsed:.2f}s for 5 requests")
# Run tests
if __name__ == "__main__":
print("=" * 70)
print("Running PubChem Tools Real API Tests")
print("=" * 70)
print()
# Run with pytest
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,113 @@
"""Validate the durable real-MCP evidence for Experiment 4-1."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
HERE = Path(__file__).resolve().parent
CAMPAIGN = HERE / "validation" / "experiment_4_1" / "real_mcp_20260729T214721Z"
def _json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def test_real_campaign_has_honest_category_statuses_and_exact_receipts():
summary = _json(CAMPAIGN / "summary.json")
assert summary["status"] == "blocked"
assert summary["receipt_count"] == 28
categories = summary["acceptance"]["categories"]
assert {name: value["status"] for name, value in categories.items()} == {
"search": "passed",
"multimodal": "blocked",
"filesystem": "passed",
"public_data": "passed",
"private_data": "blocked",
}
assert summary["acceptance"]["gates"]["filesystem_pre_post_hashes_verified"]
assert summary["acceptance"]["gates"]["filesystem_isolation_probes_rejected"]
assert summary["acceptance"]["gates"]["all_successes_substantive_and_non_simulated"]
assert not summary["acceptance"]["gates"]["private_data_category_passed"]
assert not summary["acceptance"]["gates"]["multimodal_category_passed"]
def test_legacy_catalog_came_from_mcp_and_retains_the_126_tool_contract():
catalog = _json(CAMPAIGN / "catalog_receipt.json")
assert catalog["transport"] == "mcp-stdio"
assert catalog["tools_list_received"] is True
assert catalog["server_name"] == "perception-tools"
assert catalog["tool_count"] == catalog["unique_tool_count"] == 126
names = set(catalog["tool_names"])
assert {
"web_search", "document_reader", "image_ocr", "audio_transcribe",
"filesystem_copy", "filesystem_move", "filesystem_delete",
"weather", "calendar_events", "notion_search", "code_interpreter",
} <= names
def test_retained_2026_07_29_campaign_is_explicitly_legacy_evidence():
"""The old receipt must not be mistaken for SDK v2/current-protocol proof."""
catalog = _json(CAMPAIGN / "catalog_receipt.json")
assert "mcp_sdk_version" not in catalog
assert "protocol_version" not in catalog
def test_mutations_have_hash_receipts_and_escape_attempts_failed_closed():
receipts = {
path.stem.split("_", 1)[1]: _json(path)
for path in (CAMPAIGN / "receipts").glob("*.json")
}
for case in ("filesystem_copy", "filesystem_move"):
receipt = receipts[case]
assert receipt["success"] is True
assert receipt["substantive_observation"] is True
assert receipt["payload"]["metadata"]["pre_operation_fingerprint"] == \
receipt["payload"]["message"]["destination_fingerprint"]
deleted = receipts["filesystem_delete"]
assert deleted["success"] is True
assert deleted["payload"]["message"]["reversible"] is True
assert deleted["payload"]["metadata"]["pre_operation_fingerprint"] == \
deleted["payload"]["message"]["quarantine_fingerprint"]
for case in ("reject_parent_traversal", "reject_absolute_path", "reject_escaping_symlink"):
receipt = receipts[case]
assert receipt["success"] is False
assert receipt["mcp_result_is_error"] is False
assert receipt["error_type"] == "PermissionError"
assert _json(CAMPAIGN / "summary.json")["outside_witness_unchanged"] is True
def test_credential_and_quota_blocks_are_real_failures_not_successes():
receipts = [_json(path) for path in sorted((CAMPAIGN / "receipts").glob("*.json"))]
by_case = {receipt["case"]: receipt for receipt in receipts}
for case in ("calendar_events", "notion_search"):
assert by_case[case]["success"] is False
assert by_case[case]["error_type"] == "missing_credentials"
assert by_case[case]["substantive_observation"] is False
for case in ("image_analyze", "video_analyze"):
assert by_case[case]["success"] is False
assert "insufficient_quota" in json.dumps(by_case[case]["payload"])
preflight = _json(CAMPAIGN / "credential_preflight.json")
assert preflight["secret_values_recorded"] is False
assert preflight["google_calendar"]["token_file_exists"] is False
assert preflight["notion"]["api_key_present"] is False
def test_manifest_hashes_every_durable_campaign_file():
manifest = _json(CAMPAIGN / "manifest.json")
assert manifest["file_count"] == len(manifest["files"])
assert manifest["file_count"] >= 40
for row in manifest["files"]:
path = CAMPAIGN / row["path"]
if row["kind"] == "symlink-target":
assert path.is_symlink()
data = os.readlink(path).encode("utf-8")
else:
assert path.is_file()
data = path.read_bytes()
assert len(data) == row["bytes"]
assert hashlib.sha256(data).hexdigest() == row["sha256"]
@@ -0,0 +1,19 @@
import json
from pathlib import Path
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent / "src"))
from search_tools import search_knowledge_base
@pytest.mark.asyncio
async def test_search_knowledge_base_negative_top_k(tmp_path: Path):
(tmp_path / "doc1.txt").write_text("test content one", encoding="utf-8")
(tmp_path / "doc2.txt").write_text("test content two", encoding="utf-8")
res = await search_knowledge_base("test", str(tmp_path), top_k=-1)
payload = json.loads(res.text)
assert payload["success"] is True
assert payload["message"]["total_found"] == 0
assert payload["message"]["results"] == []
@@ -0,0 +1,69 @@
"""Regression test: num_frames=0 must not cause ZeroDivisionError.
The LLM-supplied num_frames parameter was used directly as a divisor in
`frame_count // num_frames`; num_frames=0 crashed with ZeroDivisionError
(surfacing as a confusing tool error). It is now clamped to >= 1 up front.
"""
import asyncio
import json
import os
import sys
import types
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
# Optional runtime deps for importing the chapter module in unit tests.
sys.modules.setdefault("dotenv", types.SimpleNamespace(load_dotenv=lambda: None))
mcp = types.ModuleType("mcp")
mcp_types = types.ModuleType("mcp.types")
class TextContent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
mcp_types.TextContent = TextContent
sys.modules["mcp"] = mcp
sys.modules["mcp.types"] = mcp_types
import cv2
import numpy as np
import media_processing_tools
from media_processing_tools import extract_video_keyframes
def _make_clip(path, frames=20):
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
out = cv2.VideoWriter(str(path), fourcc, 10.0, (64, 48))
for _ in range(frames):
out.write(np.zeros((48, 64, 3), dtype=np.uint8))
out.release()
def test_extract_keyframes_zero_num_frames_is_clamped(tmp_path):
clip = tmp_path / "clip.mp4"
_make_clip(clip)
result = asyncio.run(extract_video_keyframes(str(clip), num_frames=0))
payload = json.loads(result.text)
assert payload["success"] is True
assert "division" not in str(payload["message"]).lower()
def test_analyze_video_ai_zero_num_frames_is_clamped(tmp_path, monkeypatch):
clip = tmp_path / "clip.mp4"
_make_clip(clip)
message = SimpleNamespace(content="a frame")
response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
client = SimpleNamespace(chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **kwargs: response)))
monkeypatch.setattr(media_processing_tools, "_make_vision_client",
lambda: (client, "fake-model"))
result = asyncio.run(media_processing_tools.analyze_video_ai(str(clip), num_frames=0))
payload = json.loads(result.text)
assert payload["success"] is True
assert "division" not in str(payload["message"]).lower()
@@ -0,0 +1,31 @@
import asyncio
import json
import os
import sys
import types
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
# Optional runtime deps for importing the chapter module in unit tests.
sys.modules.setdefault("wikipedia", types.ModuleType("wikipedia"))
sys.modules.setdefault("dotenv", types.SimpleNamespace(load_dotenv=lambda: None))
mcp = types.ModuleType("mcp")
mcp_types = types.ModuleType("mcp.types")
class TextContent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
mcp_types.TextContent = TextContent
sys.modules["mcp"] = mcp
sys.modules["mcp.types"] = mcp_types
from wiki_enhanced import get_article_history
def test_year_only_date_returns_error_payload():
result = asyncio.run(get_article_history("Python", "2025"))
payload = json.loads(result.text)
assert payload["success"] is False
msg = str(payload["message"])
assert "date must be" in msg or "Failed" in msg
@@ -0,0 +1,389 @@
"""
Real API tests for Yahoo Finance tools.
These tests make actual API calls to Yahoo Finance to verify functionality.
"""
import asyncio
import json
import pytest
from pathlib import Path
import sys
from datetime import datetime, timedelta
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from yahoo_finance_tools import (
get_stock_quote,
get_historical_data,
get_company_info,
get_financial_statements
)
class TestYFinanceQuote:
"""Tests for stock quote functionality."""
@pytest.mark.asyncio
async def test_get_stock_quote_aapl(self):
"""Test getting stock quote for AAPL."""
result = await get_stock_quote(symbol="AAPL")
data = json.loads(result.text)
assert data["success"] is True
quote = data["message"]
assert quote["symbol"] == "AAPL"
assert quote["current_price"] is not None
assert quote["current_price"] > 0
assert quote["company_name"] is not None
print(f"✅ AAPL Quote: ${quote['current_price']}")
print(f" Company: {quote['company_name']}")
if "change_percent" in quote:
print(f" Change: {quote['change_percent']}%")
@pytest.mark.asyncio
async def test_get_stock_quote_multiple(self):
"""Test getting quotes for multiple symbols."""
symbols = ["MSFT", "GOOGL", "TSLA"]
for symbol in symbols:
result = await get_stock_quote(symbol=symbol)
data = json.loads(result.text)
assert data["success"] is True
quote = data["message"]
assert quote["symbol"] == symbol
assert quote["current_price"] > 0
print(f"{symbol}: ${quote['current_price']}")
@pytest.mark.asyncio
async def test_get_stock_quote_invalid(self):
"""Test getting quote for invalid symbol."""
result = await get_stock_quote(symbol="INVALIDXYZ999")
data = json.loads(result.text)
assert data["success"] is False
assert "error" in data["message"].lower() or "not found" in data["message"].lower() or "no data" in data["message"].lower()
print("✅ Correctly handled invalid symbol")
@pytest.mark.asyncio
async def test_get_stock_quote_with_metadata(self):
"""Test that quote includes proper metadata."""
result = await get_stock_quote(symbol="AAPL")
data = json.loads(result.text)
assert data["success"] is True
metadata = data["metadata"]
assert metadata["symbol"] == "AAPL"
assert metadata["operation"] == "get_stock_quote"
assert metadata["execution_time"] is not None
assert metadata["execution_time"] > 0
assert metadata["data_points"] > 0
print(f"✅ Metadata OK: {metadata['execution_time']:.2f}s, {metadata['data_points']} fields")
class TestYFinanceHistorical:
"""Tests for historical data functionality."""
@pytest.mark.asyncio
async def test_get_historical_data_1week(self):
"""Test getting 1 week of historical data."""
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
result = await get_historical_data(
symbol="AAPL",
start=start_date.strftime("%Y-%m-%d"),
end=end_date.strftime("%Y-%m-%d"),
interval="1d",
max_rows_preview=10
)
data = json.loads(result.text)
assert data["success"] is True
hist = data["message"]
assert hist["symbol"] == "AAPL"
assert hist["total_records"] > 0
assert len(hist["data"]) > 0
# Check data structure
first_record = hist["data"][0]
assert "Close" in first_record or "close" in str(first_record).lower()
assert "Volume" in first_record or "volume" in str(first_record).lower()
print(f"✅ Retrieved {hist['total_records']} historical records")
print(f" Date range: {hist['start_date']} to {hist['end_date']}")
@pytest.mark.asyncio
async def test_get_historical_data_1month(self):
"""Test getting 1 month of historical data."""
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
result = await get_historical_data(
symbol="MSFT",
start=start_date.strftime("%Y-%m-%d"),
end=end_date.strftime("%Y-%m-%d"),
interval="1d",
max_rows_preview=5
)
data = json.loads(result.text)
assert data["success"] is True
hist = data["message"]
assert hist["total_records"] >= 20 # At least ~20 trading days in a month
print(f"✅ Retrieved {hist['total_records']} records for 1 month period")
@pytest.mark.asyncio
async def test_get_historical_data_weekly(self):
"""Test getting weekly interval data."""
end_date = datetime.now()
start_date = end_date - timedelta(days=90)
result = await get_historical_data(
symbol="AAPL",
start=start_date.strftime("%Y-%m-%d"),
end=end_date.strftime("%Y-%m-%d"),
interval="1wk",
max_rows_preview=10
)
data = json.loads(result.text)
assert data["success"] is True
hist = data["message"]
assert hist["interval"] == "1wk"
print(f"✅ Retrieved {hist['total_records']} weekly records")
class TestYFinanceCompanyInfo:
"""Tests for company information functionality."""
@pytest.mark.asyncio
async def test_get_company_info_aapl(self):
"""Test getting company info for Apple."""
result = await get_company_info(symbol="AAPL")
data = json.loads(result.text)
assert data["success"] is True
info = data["message"]
assert info["symbol"] == "AAPL"
assert info["sector"] is not None
assert info["industry"] is not None
assert info["business_summary"] is not None
assert "apple" in info["business_summary"].lower()
print(f"✅ Company Info for {info.get('long_name', info.get('short_name'))}")
print(f" Sector: {info['sector']}")
print(f" Industry: {info['industry']}")
if "full_time_employees" in info:
print(f" Employees: {info['full_time_employees']:,}")
@pytest.mark.asyncio
async def test_get_company_info_multiple(self):
"""Test getting company info for multiple companies."""
symbols = ["MSFT", "GOOGL", "AMZN"]
for symbol in symbols:
result = await get_company_info(symbol=symbol)
data = json.loads(result.text)
assert data["success"] is True
info = data["message"]
assert info["symbol"] == symbol
assert info["sector"] is not None
print(f"{symbol}: {info.get('long_name', info.get('short_name'))} - {info['sector']}")
@pytest.mark.asyncio
async def test_get_company_info_with_website(self):
"""Test that company info includes website."""
result = await get_company_info(symbol="AAPL")
data = json.loads(result.text)
assert data["success"] is True
info = data["message"]
assert "website" in info
assert "apple.com" in info["website"].lower()
print(f"✅ Website: {info['website']}")
class TestYFinanceFinancialStatements:
"""Tests for financial statements functionality."""
@pytest.mark.asyncio
async def test_get_income_statement(self):
"""Test getting income statement."""
result = await get_financial_statements(
symbol="AAPL",
statement_type="income_statement",
period_type="annual",
max_columns_preview=2
)
data = json.loads(result.text)
assert data["success"] is True
stmt = data["message"]
assert stmt["symbol"] == "AAPL"
assert stmt["statement_type"] == "income_statement"
assert stmt["period_type"] == "annual"
assert len(stmt["data"]) > 0
# Check for key income statement items
items = [item["Item"] for item in stmt["data"]]
# Usually includes items like "Total Revenue", "Net Income", etc.
assert len(items) > 10
print(f"✅ Income Statement: {stmt['total_line_items']} items, {stmt['periods']} periods")
print(f" Sample items: {', '.join(items[:3])}")
@pytest.mark.asyncio
async def test_get_balance_sheet(self):
"""Test getting balance sheet."""
result = await get_financial_statements(
symbol="MSFT",
statement_type="balance_sheet",
period_type="annual",
max_columns_preview=2
)
data = json.loads(result.text)
assert data["success"] is True
stmt = data["message"]
assert stmt["statement_type"] == "balance_sheet"
assert len(stmt["data"]) > 0
print(f"✅ Balance Sheet: {stmt['total_line_items']} items")
@pytest.mark.asyncio
async def test_get_cash_flow(self):
"""Test getting cash flow statement."""
result = await get_financial_statements(
symbol="GOOGL",
statement_type="cash_flow",
period_type="annual",
max_columns_preview=2
)
data = json.loads(result.text)
assert data["success"] is True
stmt = data["message"]
assert stmt["statement_type"] == "cash_flow"
assert len(stmt["data"]) > 0
print(f"✅ Cash Flow: {stmt['total_line_items']} items")
@pytest.mark.asyncio
async def test_get_quarterly_income_statement(self):
"""Test getting quarterly income statement."""
result = await get_financial_statements(
symbol="AAPL",
statement_type="income_statement",
period_type="quarterly",
max_columns_preview=4
)
data = json.loads(result.text)
assert data["success"] is True
stmt = data["message"]
assert stmt["period_type"] == "quarterly"
assert stmt["periods"] >= 4 # Should have at least 4 quarters
print(f"✅ Quarterly Income Statement: {stmt['periods']} quarters")
@pytest.mark.asyncio
async def test_financial_statement_invalid_type(self):
"""Test getting financial statement with invalid type."""
result = await get_financial_statements(
symbol="AAPL",
statement_type="invalid_type", # type: ignore
period_type="annual"
)
data = json.loads(result.text)
assert data["success"] is False
print("✅ Correctly handled invalid statement type")
class TestYFinanceIntegration:
"""Integration tests combining multiple operations."""
@pytest.mark.asyncio
async def test_complete_stock_analysis(self):
"""Test getting complete stock analysis data."""
symbol = "AAPL"
# Get quote
quote_result = await get_stock_quote(symbol)
quote_data = json.loads(quote_result.text)
assert quote_data["success"] is True
# Get company info
info_result = await get_company_info(symbol)
info_data = json.loads(info_result.text)
assert info_data["success"] is True
# Get historical data
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
hist_result = await get_historical_data(
symbol,
start_date.strftime("%Y-%m-%d"),
end_date.strftime("%Y-%m-%d")
)
hist_data = json.loads(hist_result.text)
assert hist_data["success"] is True
# Get income statement
stmt_result = await get_financial_statements(
symbol,
"income_statement",
"annual"
)
stmt_data = json.loads(stmt_result.text)
assert stmt_data["success"] is True
quote = quote_data["message"]
info = info_data["message"]
hist = hist_data["message"]
stmt = stmt_data["message"]
print(f"\n{'='*60}")
print(f"Complete Analysis for {symbol}")
print(f"{'='*60}")
print(f"Company: {info.get('long_name')}")
print(f"Sector: {info['sector']}")
print(f"Current Price: ${quote['current_price']}")
if "change_percent" in quote:
print(f"Change: {quote['change_percent']}%")
print(f"Historical Data: {hist['total_records']} records")
print(f"Financial Statements: {stmt['total_line_items']} line items")
print(f"{'='*60}\n")
# Run tests
if __name__ == "__main__":
print("=" * 70)
print("Running Yahoo Finance Tools Real API Tests")
print("=" * 70)
print()
# Run with pytest
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,259 @@
"""
Real API tests for YouTube transcript extraction.
These tests make actual API calls to YouTube to verify functionality.
"""
import asyncio
import json
import pytest
from pathlib import Path
import sys
# Add src to path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from multimodal_tools import extract_youtube_transcript
def _load_success_or_skip_ip_block(result):
"""Skip only YouTube's explicit provider-side IP ban response."""
data = json.loads(result.text)
if not data["success"] and "blocking requests from your IP" in str(data["message"]):
pytest.skip("YouTube transcript provider explicitly blocked this runner IP")
return data
class TestYouTubeTranscript:
"""Tests for YouTube transcript extraction."""
@pytest.mark.asyncio
async def test_extract_transcript_by_id(self):
"""Test extracting transcript by video ID."""
# Using a known video with English transcript
# Example: A TED talk or educational video
video_id = "dQw4w9WgXcQ" # A well-known video ID
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
message = data["message"]
assert message["video_id"] == video_id
assert message["language"] == "en"
assert message["total_entries"] > 0
assert len(message["transcript"]) > 0
assert message["full_text_length"] > 0
print(f"✅ Extracted transcript: {message['total_entries']} entries")
print(f" Total text length: {message['full_text_length']} chars")
print(f" First entry: {message['transcript'][0]}")
@pytest.mark.asyncio
async def test_extract_transcript_by_url(self):
"""Test extracting transcript by video URL."""
# Full YouTube URL
video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_url,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
message = data["message"]
assert message["video_id"] == "dQw4w9WgXcQ"
print(f"✅ Extracted transcript from URL")
print(f" Video ID parsed: {message['video_id']}")
@pytest.mark.asyncio
async def test_extract_transcript_short_url(self):
"""Test extracting transcript by short URL."""
# Short YouTube URL
video_url = "https://youtu.be/dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_url,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
message = data["message"]
assert message["video_id"] == "dQw4w9WgXcQ"
print(f"✅ Extracted transcript from short URL")
@pytest.mark.asyncio
async def test_extract_transcript_with_timestamps(self):
"""Test that transcript includes timestamps."""
video_id = "dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
transcript = data["message"]["transcript"]
assert len(transcript) > 0
# Check that entries have timestamps
first_entry = transcript[0]
assert "timestamp" in first_entry
assert "text" in first_entry
# Timestamp should be in MM:SS format
assert ":" in first_entry["timestamp"]
print(f"✅ Transcript has proper timestamps")
print(f" Example: {first_entry['timestamp']} - {first_entry['text'][:50]}")
@pytest.mark.asyncio
async def test_extract_transcript_full_text(self):
"""Test that full text is provided."""
video_id = "dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
message = data["message"]
assert "full_text" in message
assert len(message["full_text"]) > 0
assert message["full_text_length"] >= len(message["full_text"]) # May or may not be truncated
is_truncated = message["full_text_length"] > len(message["full_text"])
print(f"✅ Full text provided")
print(f" Preview length: {len(message['full_text'])} chars")
print(f" Total length: {message['full_text_length']} chars")
print(f" Truncated: {is_truncated}")
@pytest.mark.asyncio
async def test_extract_transcript_invalid_video(self):
"""Test extracting transcript from invalid video ID."""
result = await extract_youtube_transcript(
video_id="invalid_video_id_xyz",
language_code="en"
)
data = json.loads(result.text)
assert data["success"] is False
assert "error" in data["message"].lower() or "failed" in data["message"].lower()
print("✅ Correctly handled invalid video ID")
@pytest.mark.asyncio
async def test_extract_transcript_metadata(self):
"""Test that proper metadata is included."""
video_id = "dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
metadata = data["metadata"]
assert metadata["video_id"] == video_id
assert metadata["language"] == "en"
assert "translated" in metadata
assert metadata["translated"] is False
print(f"✅ Metadata included")
print(f" Language: {metadata['language']}")
print(f" Translated: {metadata['translated']}")
class TestYouTubeTranscriptTranslation:
"""Tests for transcript translation functionality."""
@pytest.mark.asyncio
async def test_extract_and_translate(self):
"""Test extracting and translating transcript."""
video_id = "dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en",
translate_to_language="es" # Translate to Spanish
)
data = json.loads(result.text)
# Translation might not always work, so handle both cases
if data["success"]:
message = data["message"]
assert message["language"] == "es"
assert data["metadata"]["translated"] is True
print(f"✅ Transcript translated to Spanish")
print(f" Total entries: {message['total_entries']}")
else:
# Translation failed, which is acceptable
print(f"⚠️ Translation not available for this video")
class TestYouTubeTranscriptFormats:
"""Tests for different output formats."""
@pytest.mark.asyncio
async def test_transcript_structure(self):
"""Test the structure of transcript data."""
video_id = "dQw4w9WgXcQ"
result = await extract_youtube_transcript(
video_id=video_id,
language_code="en"
)
data = _load_success_or_skip_ip_block(result)
assert data["success"] is True
message = data["message"]
# Check structure
assert "video_id" in message
assert "language" in message
assert "transcript" in message
assert "total_entries" in message
assert "full_text" in message
assert "full_text_length" in message
# Check transcript entries structure
if len(message["transcript"]) > 0:
entry = message["transcript"][0]
assert "timestamp" in entry
assert "text" in entry
print(f"✅ Transcript structure validated")
print(f" Fields: {', '.join(message.keys())}")
# Run tests
if __name__ == "__main__":
print("=" * 70)
print("Running YouTube Transcript Tools Real API Tests")
print("=" * 70)
print()
print("Note: These tests use a well-known video ID for testing.")
print("If tests fail, it might be due to YouTube API changes or regional restrictions.")
print()
# Run with pytest
pytest.main([__file__, "-v", "-s"])
@@ -0,0 +1,8 @@
{
"experiment": "4-1",
"campaign_id": "real_mcp_dashscope_intl_20260730T070000Z",
"status": "blocked",
"official_complete": false,
"manifest": "validation/experiment_4_1/real_mcp_dashscope_intl_20260730T070000Z/manifest.json",
"manifest_sha256": "f93ee0ad9bd1121ed9e7c9d730bbaf85847d03e89c9024487cfdf9f62b8557ab"
}
@@ -0,0 +1,21 @@
{
"secret_values_recorded": false,
"google_calendar": {
"token_file_exists": false,
"token_file_bytes": 0,
"oauth_credentials_sdk_importable": true,
"calendar_sdk_importable": true
},
"notion": {
"api_key_present": false,
"sdk_importable": true
},
"multimodal": {
"openai_key_present": true,
"openrouter_key_present": true,
"local_whisper_importable": true,
"pytesseract_importable": true,
"tesseract_executable_present": true,
"ffmpeg_executable_present": true
}
}
@@ -0,0 +1,100 @@
{
"marker": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"paths": {
"fixtures": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures",
"knowledge": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge",
"note": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md",
"pdf": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pdf",
"docx": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.docx",
"pptx": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pptx",
"image": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png",
"audio": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/spoken-marker.aiff",
"video": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4",
"downloads": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/downloads",
"mutation": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/mutation_workspace",
"outside_witness": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/outside-witness.txt"
},
"files": [
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md",
"bytes": 117,
"sha256": "945c0c3c5db50871410a7e3fed03927d5d8756daab542a933ced7a34b38e9bd1"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pdf",
"bytes": 1461,
"sha256": "eff86d68b4bf349032123aaf27a9d6c2dacd5f6fdc8f18f549177f11afa581a5"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.docx",
"bytes": 36652,
"sha256": "3e0c68459afab0423c06878f18f26151fb9064d8bb5f1b39c1c131534cc2087b"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pptx",
"bytes": 28277,
"sha256": "ff648361dc80bf014ce8991f92cf90413f0d315eaa2759ca7362304fba33fb66"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png",
"bytes": 15559,
"sha256": "c0d7cf1025cdafa6c9da51e0ab021f9a7fc192b874770c30f693c81ade651e6f"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/spoken-marker.aiff",
"bytes": 160516,
"sha256": "8d6b6aca4973239afafe0e42d576b8702f5fc6c008907dc75247923adfd0150f"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4",
"bytes": 10089,
"sha256": "05ed43bb4e1b34e491bcad877137721ccbdbbd5a6c4b6ee0483b20176484ef99"
},
{
"path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/outside-witness.txt",
"bytes": 28,
"sha256": "59049c8e49e561bbe617bd612757e0c83d5623857aeb123338d573e01b4c9120"
}
],
"generators": {
"say": {
"executable": "say",
"arguments": [
"-v",
"Samantha",
"-r",
"150",
"-o",
"/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/spoken-marker.aiff",
"Experiment four one. Perception tools verified."
],
"returncode": 0,
"stdout_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"elapsed_seconds": 1.151
},
"ffmpeg": {
"executable": "ffmpeg",
"arguments": [
"-y",
"-loglevel",
"error",
"-loop",
"1",
"-i",
"/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png",
"-t",
"1.5",
"-r",
"3",
"-pix_fmt",
"yuv420p",
"/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4"
],
"returncode": 0,
"stdout_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"elapsed_seconds": 0.079
}
}
}
@@ -0,0 +1,68 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 7 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 6 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/PageMode /UseNone /Pages 6 0 R /Type /Catalog
>>
endobj
5 0 obj
<<
/Author (anonymous) /CreationDate (D:20260730054301+08'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20260730054301+08'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
6 0 obj
<<
/Count 1 /Kids [ 3 0 R ] /Type /Pages
>>
endobj
7 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 149
>>
stream
Gap@E_$\%E&4>pbMB#Vum?GTH[Bm*aejY18QPL99V"E$[G:F42]HfmARcNXm%%N,@&4ctb&ARsI$Y<bWr24[kqH/=e)dPYFoZY4"L3J7!"=1[P2)/E`Vg!dGBhrBD],?:#bnb\X]CcoX(I=l=4T~>endstream
endobj
xref
0 8
0000000000 65535 f
0000000073 00000 n
0000000104 00000 n
0000000211 00000 n
0000000414 00000 n
0000000482 00000 n
0000000778 00000 n
0000000837 00000 n
trailer
<<
/ID
[<b8741d3b1ffae99288560bb1e72812c6><b8741d3b1ffae99288560bb1e72812c6>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 5 0 R
/Root 4 0 R
/Size 8
>>
startxref
1076
%%EOF
@@ -0,0 +1,201 @@
<!doctype html>
<html>
<head>
<title>Example Domains</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/css/iana_website.3c174467e53c.css"/>
<link rel="shortcut icon" type="image/ico" href="/static/img/bookmark_icon.e14a2530b3e9.ico"/>
<script type="text/javascript" src="/static/js/jquery.a8e7cabd4d49.js"></script>
<script type="text/javascript" src="/static/js/dtable.46ee921d4414.js" defer></script>
<script type="text/javascript" src="/static/js/relative-time.79f0e30be3b8.js" defer></script>
</head>
<body>
<header>
<div id="header">
<div id="logo">
<a href="/"><img src="/static/img/iana-logo-header.426b3ac01d35.svg" alt="Homepage"/></a>
</div>
<div class="navigation">
<ul>
<li><a href="/domains">Domains</a></li>
<li><a href="/protocols">Protocols</a></li>
<li><a href="/numbers">Numbers</a></li>
<li><a href="/about">About</a></li>
</ul>
</div>
</div>
</header>
<div id="body">
<article class="hemmed sidenav">
<main>
<ol class="breadcrumb">
<li><a href="/help">Instructions and Guides</a></li>
</ol>
<h1 id="example-domains">Example Domains</h1>
<div class="help-article-flow">
<div class="help-article">
<p>As described in <a href="/go/rfc2606">RFC 2606</a> and <a href="/go/rfc6761">RFC 6761</a>, a
number of domains such as example.com and example.org are maintained
for documentation purposes. These domains may be used as illustrative
examples in documents without prior coordination with us. They are not
available for registration or transfer.</p>
<p>We provide a web service on the example domain hosts to provide basic
information on the purpose of the domain. These web services are
provided as best effort, but are not designed to support production
applications. While incidental traffic for incorrectly configured
applications is expected, please do not design applications that require
the example domains to have operating HTTP service.</p>
<h2 id="further-reading">Further Reading</h2>
<ul>
<li><a href="/domains/reserved">IANA-managed Reserved Domains</a></li>
</ul>
<div class="last-updated">Last revised <time class="relative-time" datetime="2017-05-13">2017-05-13</time>.</div>
</div>
</div>
</main>
<nav id="sidenav">
</nav>
</article>
</div>
<footer>
<div id="footer">
<table class="navigation">
<tr>
<td class="section"><a href="/domains">Domain&nbsp;Names</a></td>
<td class="subsection">
<ul>
<li><a href="/domains/root">Root Zone Registry</a></li>
<li><a href="/domains/int">.INT Registry</a></li>
<li><a href="/domains/arpa">.ARPA Registry</a></li>
<li><a href="/domains/idn-tables">IDN Repository</a></li>
</ul>
</td>
</tr>
<tr>
<td class="section"><a href="/numbers">Number&nbsp;Resources</a></td>
<td class="subsection">
<ul>
<li><a href="/numbers/registries">Number Registries</a></li>
<li><a href="/numbers/allocations/">RIR Allocation Data</a></li>
<li><a href="/help/abuse">Abuse Information</a></li>
</ul>
</td>
</tr>
<tr>
<td class="section"><a href="/protocols">Protocols</a></td>
<td class="subsection">
<ul>
<li><a href="/protocols">Protocol Registries</a></li>
<li><a href="/time-zones">Time Zone Database</a></li>
</ul>
</td>
</tr>
<tr>
<td class="section"><a href="/about">About&nbsp;Us</a></td>
<td class="subsection">
<ul>
<li><a href="/news">News</a></li>
<li><a href="/performance">Performance</a></li>
<li><a href="/about/organization">The Organization</a></li>
<li><a href="/archive">Archive</a></li>
<li><a href="/contact">Contact Us</a></li>
</ul>
</td>
</tr>
</table>
<div id="custodian">
<p>The IANA functions coordinate the Internets globally unique identifiers, and
are provided by <a href="https://pti.icann.org">Public Technical Identifiers</a>, an affiliate of
<a href="https://www.icann.org/">ICANN</a>.</p>
</div>
<div id="legalnotice">
<ul>
<li><a href="https://www.icann.org/privacy/policy">Privacy Policy</a></li>
<li><a href="https://www.icann.org/privacy/tos">Terms of Service</a></li>
</ul>
</div>
</div>
</footer>
<script>
// Highlight the table-of-contents entry for the section currently at the
// top of the viewport, following the page as it scrolls. Purely additive:
// without JS the rail still works as a list of jump links.
(function () {
var toc = document.querySelector('.article-toc');
if (!toc) return;
var sections = Array.prototype.map.call(
toc.querySelectorAll('a[href^="#"]'),
function (a) {
var id = decodeURIComponent(a.getAttribute('href')).slice(1);
return { link: a, target: document.getElementById(id) };
}
).filter(function (s) { return s.target; });
if (!sections.length) return;
var current = null;
function update() {
var line = 120; // trigger line, px below the top of the viewport
var active = sections[0];
for (var i = 0; i < sections.length; i++) {
if (sections[i].target.getBoundingClientRect().top - line <= 0) {
active = sections[i];
}
}
if (active && active.link !== current) {
if (current) current.classList.remove('is-current');
active.link.classList.add('is-current');
current = active.link;
}
}
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(function () { update(); ticking = false; });
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
})();
</script>
</body>
</html>
@@ -0,0 +1,4 @@
# Experiment 4-1
PERCEPTION-EXPERIMENT-4-1-VERIFIED
The Model Context Protocol connects agents to perception tools.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/outside-witness.txt
@@ -0,0 +1,282 @@
{
"generated_at": "2026-07-29T21:43:53.508692+00:00",
"file_count": 46,
"files": [
{
"path": "catalog_receipt.json",
"kind": "file",
"bytes": 249214,
"sha256": "829aeb8aaa589beb85b195d8702c23015ea8a62296d18568d7ac8337b14a1d2f"
},
{
"path": "credential_preflight.json",
"kind": "file",
"bytes": 528,
"sha256": "b5e240ff7f98038b33e57f9497890affb1d46597b7a607d0d3527f01fd2a0e1b"
},
{
"path": "fixture_receipt.json",
"kind": "file",
"bytes": 5541,
"sha256": "b20c3ff7c1ebcc557d8040b9a8faa0d18dd60b8c2acbdae7f5afd6ee6ca04e46"
},
{
"path": "fixtures/documents/sample.docx",
"kind": "file",
"bytes": 36652,
"sha256": "3e0c68459afab0423c06878f18f26151fb9064d8bb5f1b39c1c131534cc2087b"
},
{
"path": "fixtures/documents/sample.pdf",
"kind": "file",
"bytes": 1461,
"sha256": "eff86d68b4bf349032123aaf27a9d6c2dacd5f6fdc8f18f549177f11afa581a5"
},
{
"path": "fixtures/documents/sample.pptx",
"kind": "file",
"bytes": 28277,
"sha256": "ff648361dc80bf014ce8991f92cf90413f0d315eaa2759ca7362304fba33fb66"
},
{
"path": "fixtures/downloads/iana-example.html",
"kind": "file",
"bytes": 6639,
"sha256": "4142da6d9cc75147bb23d15b735a5f6a472551177bda1eef62a0096c702d6e53"
},
{
"path": "fixtures/knowledge/mcp-notes.md",
"kind": "file",
"bytes": 117,
"sha256": "945c0c3c5db50871410a7e3fed03927d5d8756daab542a933ced7a34b38e9bd1"
},
{
"path": "fixtures/media/ocr-source.png",
"kind": "file",
"bytes": 15559,
"sha256": "c0d7cf1025cdafa6c9da51e0ab021f9a7fc192b874770c30f693c81ade651e6f"
},
{
"path": "fixtures/media/spoken-marker.aiff",
"kind": "file",
"bytes": 160516,
"sha256": "8d6b6aca4973239afafe0e42d576b8702f5fc6c008907dc75247923adfd0150f"
},
{
"path": "fixtures/media/visual-marker.mp4",
"kind": "file",
"bytes": 10089,
"sha256": "05ed43bb4e1b34e491bcad877137721ccbdbbd5a6c4b6ee0483b20176484ef99"
},
{
"path": "fixtures/mutation_workspace/.perception-trash/20260729T214327244925Z-e39664e96bda47b49e30d24b5b626fa4-moved.txt",
"kind": "file",
"bytes": 35,
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc"
},
{
"path": "fixtures/mutation_workspace/escape-link",
"kind": "symlink-target",
"bytes": 28,
"sha256": "59049c8e49e561bbe617bd612757e0c83d5623857aeb123338d573e01b4c9120"
},
{
"path": "fixtures/mutation_workspace/nested/entry.txt",
"kind": "file",
"bytes": 25,
"sha256": "ec677abe859c618dc5ac4e89534031eddaf90c1c8a1f45f68d55ab3af39eb896"
},
{
"path": "fixtures/mutation_workspace/seed.txt",
"kind": "file",
"bytes": 35,
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc"
},
{
"path": "fixtures/outside-witness.txt",
"kind": "file",
"bytes": 28,
"sha256": "59049c8e49e561bbe617bd612757e0c83d5623857aeb123338d573e01b4c9120"
},
{
"path": "protocol.json",
"kind": "file",
"bytes": 1822,
"sha256": "1fd1e8a23c8e27de4bf1f038b6953fb588b4748770e06efb2dd90feecb4a2901"
},
{
"path": "receipts/01_web_search.json",
"kind": "file",
"bytes": 763,
"sha256": "c3d82644ab244c6adbf0315758d20a0e232d567206482d2d9e7d92c60acbbfc0"
},
{
"path": "receipts/02_knowledge_base_search.json",
"kind": "file",
"bytes": 1291,
"sha256": "09fc433d395630668c0555966a35e454b11e14e6d2f091b25e12748286866fd6"
},
{
"path": "receipts/03_download.json",
"kind": "file",
"bytes": 1247,
"sha256": "2923f78df32c909901b89ab7b00e27318fee6aec5a3da1a91633403c466c2d62"
},
{
"path": "receipts/04_webpage_reader.json",
"kind": "file",
"bytes": 1051,
"sha256": "5b388ef9e48f84c5e262bbbd39bc7864a27fd3d648ec76b48273a219ba76965f"
},
{
"path": "receipts/05_document_reader_pdf.json",
"kind": "file",
"bytes": 1087,
"sha256": "50d79059daa7dc2b94725585de1cc119e91e45e2f545436eececfc1220f54bfc"
},
{
"path": "receipts/06_document_reader_docx.json",
"kind": "file",
"bytes": 1096,
"sha256": "67afb85b1ae53d12e655aae117a78a299353623b76fd4aba6d7f0340fdcfccf3"
},
{
"path": "receipts/07_document_reader_pptx.json",
"kind": "file",
"bytes": 1094,
"sha256": "fddbddf3fced492d18897fccebad9be3ccaace9f71f3b3cb0d74c313c522cd5e"
},
{
"path": "receipts/08_image_ocr.json",
"kind": "file",
"bytes": 1149,
"sha256": "4b1913bde9ca7d4121861b02bd6f1a300d38aaa887508e1e8c1f6a43409ede1e"
},
{
"path": "receipts/09_image_analyze.json",
"kind": "file",
"bytes": 1165,
"sha256": "25ca96c43871914a3e879f8dd5863334112f1fdbce71c29cccde38fe7432ec6f"
},
{
"path": "receipts/10_audio_transcribe.json",
"kind": "file",
"bytes": 1138,
"sha256": "4f5fdce93df954f173bf598ad31f392cceb103e328eef1e95233ec2e76342b62"
},
{
"path": "receipts/11_video_parser.json",
"kind": "file",
"bytes": 1093,
"sha256": "0f4b6dd5b42df14130f71cbd063b64d4c4f9649df4e55b6e08c59de25478eb83"
},
{
"path": "receipts/12_video_analyze.json",
"kind": "file",
"bytes": 1183,
"sha256": "302e5225d0c8c7de220c23411cf9bd7e481990d4a54c15986ba41c9895dda6a6"
},
{
"path": "receipts/13_file_reader.json",
"kind": "file",
"bytes": 1270,
"sha256": "3a32d31ff5a2a7a9eb4db40f977d48e4c64da6dadd5720a5a6a41728dbfc5b4c"
},
{
"path": "receipts/14_grep.json",
"kind": "file",
"bytes": 1436,
"sha256": "a2a1c3185916be96847d5e70141c0e95446c2b7a91b706a6fc3434212bbacc35"
},
{
"path": "receipts/15_directory_list.json",
"kind": "file",
"bytes": 1041,
"sha256": "ae79510eb4695a40149688a80b09457bcf185f609f013b0a523eaf4f7a6d951b"
},
{
"path": "receipts/16_filesystem_copy.json",
"kind": "file",
"bytes": 1424,
"sha256": "4b128f16cd3b3f84d3bfc9ae952ba7530dad7b5d46e8005c2ac71413c209ff36"
},
{
"path": "receipts/17_filesystem_move.json",
"kind": "file",
"bytes": 1427,
"sha256": "cb0b01bb53737e7714f6875f4786c679533555d861564dff3901d448063e4607"
},
{
"path": "receipts/18_filesystem_delete.json",
"kind": "file",
"bytes": 1456,
"sha256": "56bc1a91f6cbbf390142d4dc5b0c373ef0ae4f815441fb3b40a7e75feaf561f7"
},
{
"path": "receipts/19_reject_parent_traversal.json",
"kind": "file",
"bytes": 851,
"sha256": "e1216a09fdbef8b4d53420a882ff9ba91257e05ea0941dfd2edd01e6a3904b4b"
},
{
"path": "receipts/20_reject_absolute_path.json",
"kind": "file",
"bytes": 806,
"sha256": "62cec79620f151ed87026ee33c750fa7e0550495de58938548c1d78994ce9642"
},
{
"path": "receipts/21_reject_escaping_symlink.json",
"kind": "file",
"bytes": 811,
"sha256": "9d2d4467851dfdbbb0f279cf9b9f2f9670d00d54d1e9e94eedbd810d698fecce"
},
{
"path": "receipts/22_weather.json",
"kind": "file",
"bytes": 1074,
"sha256": "20074ed5fe8efccf187226fdd13f07741128a17e9409641fab427fd422c4a9f3"
},
{
"path": "receipts/23_yfinance_quote.json",
"kind": "file",
"bytes": 1246,
"sha256": "97a7cd95d90afc26a8205d17eb5951a625427c147b5ff2be1a088206627618d9"
},
{
"path": "receipts/24_currency_converter.json",
"kind": "file",
"bytes": 820,
"sha256": "36990707b44290542f5c5f7c51b9274579ccd7d422d7adbb0ea5c0c02a4d9bcb"
},
{
"path": "receipts/25_wikipedia_search.json",
"kind": "file",
"bytes": 1463,
"sha256": "b9ea01e0777a801ad439ee178cc44663bbd23ad439a0bf676f62446075297fde"
},
{
"path": "receipts/26_arxiv_search.json",
"kind": "file",
"bytes": 2654,
"sha256": "e8a32e84a3e1f189d8df414b0cabc79e5d1dc7746f05d91d043a3dbe9758c681"
},
{
"path": "receipts/27_calendar_events.json",
"kind": "file",
"bytes": 729,
"sha256": "76d332afe6814579c0d118f08bde91f312ca523b0d8d9963d75300db4f624ce1"
},
{
"path": "receipts/28_notion_search.json",
"kind": "file",
"bytes": 700,
"sha256": "bef9ac758b3bf75b86ea093da1e39e520bbf1cc9bb4581907d915ca99580e13d"
},
{
"path": "summary.json",
"kind": "file",
"bytes": 3720,
"sha256": "8ff1e57675448a98174177c452a0f633dfc97e9f7bed7c9912c1ef7f0b51843a"
}
]
}
@@ -0,0 +1,71 @@
{
"experiment": "4-1",
"title": "Perception tools MCP server five-category live campaign",
"authority": "book/chapter4.md:166",
"transport": "mcp-stdio",
"fail_closed": true,
"mutation_root_environment": "PERCEPTION_MUTATION_ROOT",
"categories": {
"search": {
"required_cases": [
"web_search",
"knowledge_base_search",
"download"
]
},
"multimodal": {
"required_cases": [
"webpage_reader",
"document_reader_pdf",
"document_reader_docx",
"document_reader_pptx",
"image_ocr",
"image_analyze",
"audio_transcribe",
"video_parser",
"video_analyze"
]
},
"filesystem": {
"required_cases": [
"file_reader",
"grep",
"directory_list",
"filesystem_copy",
"filesystem_move",
"filesystem_delete"
],
"required_safety_cases": [
"reject_parent_traversal",
"reject_absolute_path",
"reject_escaping_symlink"
]
},
"public_data": {
"required_cases": [
"weather",
"yfinance_quote",
"currency_converter",
"wikipedia_search",
"arxiv_search"
]
},
"private_data": {
"required_cases": [
"calendar_events",
"notion_search"
],
"credential_blocking_allowed": true
}
},
"acceptance": {
"catalog_from_real_mcp": true,
"catalog_contains_all_required_tools": true,
"every_success_is_substantive": true,
"filesystem_receipts_include_pre_post_hashes": true,
"filesystem_isolation_probes_are_rejected": true,
"private_sources_require_live_authorized_success": true,
"missing_or_invalid_credentials_never_pass": true,
"manifest_hashes_every_campaign_file": true
}
}
@@ -0,0 +1,27 @@
{
"case": "web_search",
"tool": "web_search",
"arguments": {
"query": "Model Context Protocol official specification",
"num_results": 3
},
"arguments_sha256": "f5d475b1c7202a0c7c2a0eba3e63e569ee6a65f46929b097a30983506ac9ebe7",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "duckduckgo-live-search",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": "search_error",
"payload": {
"success": false,
"message": "Search operation failed: 400 Client Error: Bad Request for url: https://google.serper.dev/search",
"metadata": {
"error_type": "search_error"
}
},
"elapsed_seconds": 5.112
}
@@ -0,0 +1,39 @@
{
"case": "knowledge_base_search",
"tool": "knowledge_base_search",
"arguments": {
"query": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"knowledge_base_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge",
"top_k": 3
},
"arguments_sha256": "40769215352e541225efba2d1b46483f82cb5bced24de078bd042102894ba94e",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-knowledge-files",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"query": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"results": [
{
"file": "mcp-notes.md",
"snippet": "# Experiment 4-1\n\nPERCEPTION-EXPERIMENT-4-1-VERIFIED\nThe Model Context Protocol connects agents to perception tools.",
"relevance": 1
}
],
"total_found": 1
},
"metadata": {
"knowledge_base": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge",
"top_k": 3
}
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,31 @@
{
"case": "download",
"tool": "download",
"arguments": {
"url": "https://www.iana.org/help/example-domains",
"output_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/downloads/iana-example.html",
"timeout": 60
},
"arguments_sha256": "0953bd1cc2d839b57edec8ec3d9beef838eef1dc459ad93dd3984f22a0445cc1",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "tls-http-download",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": "Successfully downloaded file to /Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/downloads/iana-example.html",
"metadata": {
"url": "https://www.iana.org/help/example-domains",
"output_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/downloads/iana-example.html",
"file_size_bytes": 6639,
"duration_seconds": 3.3426239490509033
}
},
"elapsed_seconds": 3.345
}
@@ -0,0 +1,39 @@
{
"case": "webpage_reader",
"tool": "webpage_reader",
"arguments": {
"url": "https://example.com",
"extract_text": true,
"extract_links": true
},
"arguments_sha256": "8e00a9ee1bbd11c9194efa2e61a4f1d6e01c603ab2a0feed14620c5663a70d8a",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "tls-http-beautifulsoup",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"url": "https://example.com",
"title": "Example Domain",
"text": "Example DomainExample DomainThis domain is for use in documentation examples without needing permission. Avoid use in operations.Learn more",
"text_length": 139,
"links": [
{
"text": "Learn more",
"href": "https://iana.org/domains/example"
}
]
},
"metadata": {
"url": "https://example.com"
}
},
"elapsed_seconds": 1.59
}
@@ -0,0 +1,33 @@
{
"case": "document_reader_pdf",
"tool": "document_reader",
"arguments": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pdf"
},
"arguments_sha256": "c7799b91fd6b7d3d04fa8cd9695afff55a5a6b19d8c044b02a2de01ed9736c8d",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "format-aware-local-parser",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "sample.pdf",
"file_type": "pdf",
"page_count": 1,
"text": "Experiment 4-1 PDF PERCEPTION-EXPERIMENT-4-1-VERIFIED\n\n",
"text_length": 55
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pdf",
"file_type": ".pdf"
}
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,33 @@
{
"case": "document_reader_docx",
"tool": "document_reader",
"arguments": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.docx"
},
"arguments_sha256": "33409163be69e104f84e735962f58801dde19322ed6f405a3d3baaffa93d6113",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "format-aware-local-parser",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "sample.docx",
"file_type": "docx",
"paragraph_count": 2,
"text": "Experiment 4-1 DOCX\nPERCEPTION-EXPERIMENT-4-1-VERIFIED",
"text_length": 54
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.docx",
"file_type": ".docx"
}
},
"elapsed_seconds": 0.007
}
@@ -0,0 +1,33 @@
{
"case": "document_reader_pptx",
"tool": "document_reader",
"arguments": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pptx"
},
"arguments_sha256": "5401f22ad72154f7dcac2630e081504e758ed4214ffff59bd38387c8a4d86500",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "format-aware-local-parser",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "sample.pptx",
"file_type": "pptx",
"slide_count": 1,
"text": "Experiment 4-1 PPTX\nPERCEPTION-EXPERIMENT-4-1-VERIFIED\n",
"text_length": 55
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/documents/sample.pptx",
"file_type": ".pptx"
}
},
"elapsed_seconds": 0.005
}
@@ -0,0 +1,38 @@
{
"case": "image_ocr",
"tool": "image_ocr",
"arguments": {
"image_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png",
"language": "eng"
},
"arguments_sha256": "e5d85eee000adbbfdee1a76e8ec9a4efd06e856053980d359ff238252a22d8a1",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-tesseract-ocr",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "ocr-source.png",
"image_size": [
1200,
360
],
"extracted_text": "EXPERIMENT 4-1\nPERCEPTION TOOLS VERIFIED\n",
"text_length": 41,
"word_count": 5,
"language": "eng",
"method": "pytesseract"
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png"
}
},
"elapsed_seconds": 0.231
}
@@ -0,0 +1,27 @@
{
"case": "image_analyze",
"tool": "image_analyze",
"arguments": {
"image_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/ocr-source.png",
"prompt": "Read the prominent text and describe the simple image."
},
"arguments_sha256": "cc76995033d16f6bdbfaf935706b1a7b3a7b7ea22ee192292b87847ede388bc3",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "configured-vision-api",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": "ai_analysis_error",
"payload": {
"success": false,
"message": "AI image analysis failed: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}",
"metadata": {
"error_type": "ai_analysis_error"
}
},
"elapsed_seconds": 5.524
}
@@ -0,0 +1,35 @@
{
"case": "audio_transcribe",
"tool": "audio_transcribe",
"arguments": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/spoken-marker.aiff",
"model_size": "tiny",
"language": "en"
},
"arguments_sha256": "dd452e61ffbdefcf601f63154401301cd6dcc70275eae8c3ed5041b20d05efc2",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-whisper-or-openai",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "spoken-marker.aiff",
"file_type": ".aiff",
"model": "tiny",
"language": "en",
"transcription": " Experiment 4-1, Perception Tools Verified",
"word_count": 5
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/spoken-marker.aiff"
}
},
"elapsed_seconds": 2.456
}
@@ -0,0 +1,35 @@
{
"case": "video_parser",
"tool": "video_parser",
"arguments": {
"video_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4",
"extract_frames": false
},
"arguments_sha256": "c4e5d42811975625f7f3717d57a2d9160f22b72e059ba3761f6c154d6ce63a97",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-opencv",
"origin": "local-process"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_name": "visual-marker.mp4",
"duration_seconds": 1.6666666666666667,
"fps": 3.0,
"frame_count": 5,
"resolution": "1200x360",
"width": 1200,
"height": 360
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4"
}
},
"elapsed_seconds": 0.027
}
@@ -0,0 +1,28 @@
{
"case": "video_analyze",
"tool": "video_analyze",
"arguments": {
"video_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/media/visual-marker.mp4",
"num_frames": 1,
"prompt": "Read the text shown in this frame."
},
"arguments_sha256": "fc90946394bfe61d2b3087106b02b1d1a3b563be4eb57e01bdf1ccc06b5f6698",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "opencv-and-configured-vision-api",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": "video_analysis_error",
"payload": {
"success": false,
"message": "Video analysis failed: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}",
"metadata": {
"error_type": "video_analysis_error"
}
},
"elapsed_seconds": 4.847
}
@@ -0,0 +1,33 @@
{
"case": "file_reader",
"tool": "file_reader",
"arguments": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md",
"max_length": 2000
},
"arguments_sha256": "bce18164b9cb020d001c56c9c3462b6204da2448667b4196613fc2f0431e3581",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-filesystem",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md",
"content": "# Experiment 4-1\n\nPERCEPTION-EXPERIMENT-4-1-VERIFIED\nThe Model Context Protocol connects agents to perception tools.\n",
"size_bytes": 117,
"truncated": false,
"encoding": "utf-8"
},
"metadata": {
"file_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md"
}
},
"elapsed_seconds": 0.001
}
@@ -0,0 +1,43 @@
{
"case": "grep",
"tool": "grep",
"arguments": {
"pattern": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"directory": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge",
"file_pattern": "*.md",
"max_results": 10
},
"arguments_sha256": "6f5dcf0da921249caa42bd1fc9ee1b3065384b834b30bcab706d153e2a819411",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-regex-filesystem-search",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"pattern": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"results": [
{
"file": "mcp-notes.md",
"line_number": 3,
"line": "PERCEPTION-EXPERIMENT-4-1-VERIFIED",
"absolute_path": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge/mcp-notes.md"
}
],
"total_found": 1,
"truncated": false
},
"metadata": {
"directory": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/knowledge",
"file_pattern": "*.md",
"recursive": true
}
},
"elapsed_seconds": 0.001
}
@@ -0,0 +1,42 @@
{
"case": "directory_list",
"tool": "directory_list",
"arguments": {
"query": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/mutation_workspace",
"options_json": "{\"limit\": 20}"
},
"arguments_sha256": "45888ee033d46137d27f174d745c4f31771c943dfcdf8cbbad9bd0ad72a0b8f1",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "local-filesystem",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"tool": "directory_list",
"backend": "filesystem",
"data": [
{
"name": "escape-link",
"type": "file",
"size": 28
},
{
"name": "nested",
"type": "directory",
"size": 96
},
{
"name": "seed.txt",
"type": "file",
"size": 35
}
]
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,46 @@
{
"case": "filesystem_copy",
"tool": "filesystem_copy",
"arguments": {
"source_path": "seed.txt",
"destination_path": "copied.txt"
},
"arguments_sha256": "04e57c23d7913d6e4f1076b79fd13ebe887fd4387d676d6a058e4eb81f38633e",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "workspace-confined-copy",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"operation": "copy",
"source": "seed.txt",
"destination": "copied.txt",
"source_exists_after": true,
"destination_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
},
"replaced_path_quarantine": null
},
"metadata": {
"mutation_root": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/mutation_workspace",
"pre_operation_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
},
"verification": "source retained and destination fingerprint matches"
}
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,46 @@
{
"case": "filesystem_move",
"tool": "filesystem_move",
"arguments": {
"source_path": "copied.txt",
"destination_path": "moved.txt"
},
"arguments_sha256": "57260877dce1942e47de6c09e86bf0e2c74aac0f83d2eb41ef4f2abd7f16638e",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "workspace-confined-rename",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"operation": "move",
"source": "copied.txt",
"destination": "moved.txt",
"source_exists_after": false,
"destination_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
},
"replaced_path_quarantine": null
},
"metadata": {
"mutation_root": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/mutation_workspace",
"pre_operation_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
},
"verification": "source absent and destination fingerprint matches"
}
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,45 @@
{
"case": "filesystem_delete",
"tool": "filesystem_delete",
"arguments": {
"path": "moved.txt"
},
"arguments_sha256": "05ca93abb4caf2ca737949e5e7829d839c513edbfad1e44f00b95b3ce49fc1bd",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "workspace-confined-quarantine",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"operation": "delete",
"path": "moved.txt",
"path_exists_after": false,
"quarantine_path": ".perception-trash/20260729T214327244925Z-e39664e96bda47b49e30d24b5b626fa4-moved.txt",
"reversible": true,
"quarantine_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
}
},
"metadata": {
"mutation_root": "/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/mutation_workspace",
"pre_operation_fingerprint": {
"kind": "file",
"sha256": "e088e8ab3d0dd13410c3b99f54f88aa805ad66d63294dd27f987722ede86f9fc",
"bytes": 35,
"entries": 1
},
"verification": "original path absent and quarantine fingerprint matches"
}
},
"elapsed_seconds": 0.002
}
@@ -0,0 +1,29 @@
{
"case": "reject_parent_traversal",
"tool": "filesystem_copy",
"arguments": {
"source_path": "seed.txt",
"destination_path": "../escaped.txt"
},
"arguments_sha256": "f30b8515b7a09f4b2e769eb600c8dc378524249cc9d6bb311673a20d1b74d6f2",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "workspace-confined-copy",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": "PermissionError",
"payload": {
"success": false,
"message": "Filesystem copy failed: Parent traversal is not allowed for filesystem mutations",
"metadata": {
"operation": "copy",
"error_type": "PermissionError",
"mutation_root_env": "PERCEPTION_MUTATION_ROOT"
}
},
"elapsed_seconds": 0.001
}
@@ -0,0 +1,28 @@
{
"case": "reject_absolute_path",
"tool": "filesystem_delete",
"arguments": {
"path": "/tmp"
},
"arguments_sha256": "ef28a75876b54cb665beeb4cb10d72f463c59d196f5ee4a08e62ac67e7247639",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "workspace-confined-quarantine",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": "PermissionError",
"payload": {
"success": false,
"message": "Filesystem delete failed: Absolute paths are not allowed for filesystem mutations",
"metadata": {
"operation": "delete",
"error_type": "PermissionError",
"mutation_root_env": "PERCEPTION_MUTATION_ROOT"
}
},
"elapsed_seconds": 0.001
}
@@ -0,0 +1,28 @@
{
"case": "reject_escaping_symlink",
"tool": "filesystem_delete",
"arguments": {
"path": "escape-link"
},
"arguments_sha256": "0cda34db2d162698b7076bdf382db85514c3506df0f19278a094153ec55e3fe2",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": false,
"substantive_observation": false,
"backend_provenance": {
"backend": "workspace-confined-quarantine",
"origin": "local-filesystem"
},
"simulation_markers": [],
"error_type": "PermissionError",
"payload": {
"success": false,
"message": "Filesystem delete failed: Resolved path escapes the configured mutation root",
"metadata": {
"operation": "delete",
"error_type": "PermissionError",
"mutation_root_env": "PERCEPTION_MUTATION_ROOT"
}
},
"elapsed_seconds": 0.001
}
@@ -0,0 +1,44 @@
{
"case": "weather",
"tool": "weather",
"arguments": {
"location": "Singapore"
},
"arguments_sha256": "6b05a9aa723d910bd7a8a6b4579e099e8fcbb826fac7ee89cd347f9c274ce035",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "open-meteo",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"location": "Singapore",
"country": "Singapore",
"latitude": 1.28967,
"longitude": 103.85007,
"temperature": 26.5,
"feels_like": 31.6,
"humidity": 87,
"precipitation": 0.0,
"weather_code": 3,
"description": "Overcast",
"wind_speed": 7.2,
"wind_direction": 158,
"units": "metric",
"timestamp": "2026-07-30T05:30",
"provider": "Open-Meteo"
},
"metadata": {
"location": "Singapore",
"provider": "Open-Meteo",
"api_key_required": false
}
},
"elapsed_seconds": 4.528
}
@@ -0,0 +1,48 @@
{
"case": "yfinance_quote",
"tool": "yfinance_quote",
"arguments": {
"symbol": "AAPL"
},
"arguments_sha256": "81c8d84ddf020b1584fa351351da6f46b756261e048fe93502b5f5c3fdc1e526",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "yahoo-finance-yfinance",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"symbol": "AAPL",
"company_name": "Apple Inc.",
"current_price": 338.19,
"previous_close": 340.08,
"open": 339.69,
"day_high": 344.5699,
"day_low": 337.3501,
"volume": 48852885,
"average_volume": 55895206,
"market_cap": 4967117094912,
"fifty_two_week_high": 344.5699,
"fifty_two_week_low": 201.5,
"currency": "USD",
"exchange": "NMS",
"change": -1.89,
"change_percent": -0.56
},
"metadata": {
"symbol": "AAPL",
"operation": "get_stock_quote",
"execution_time": 4.208232879638672,
"data_points": 16,
"error_type": null,
"timestamp": "2026-07-30T05:43:35.987623"
}
},
"elapsed_seconds": 4.212
}
@@ -0,0 +1,35 @@
{
"case": "currency_converter",
"tool": "currency_converter",
"arguments": {
"amount": 10,
"from_currency": "USD",
"to_currency": "SGD"
},
"arguments_sha256": "8e2c229a505df791071d022f2070870bc3da118c47b105afe6d7ced02fbed2d2",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "live-exchange-rate-api",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"amount": 10.0,
"from_currency": "USD",
"to_currency": "SGD",
"exchange_rate": 1.29,
"converted_amount": 12.9,
"timestamp": "2026-07-29"
},
"metadata": {
"rate": 1.29
}
},
"elapsed_seconds": 1.684
}
@@ -0,0 +1,39 @@
{
"case": "wikipedia_search",
"tool": "wikipedia_search",
"arguments": {
"query": "Model Context Protocol",
"language": "en",
"sentences": 3
},
"arguments_sha256": "697f2deabfb52773a614f77f31c5e61400afcc7b42b4ba0a282fc2d5b488d9df",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "mediawiki",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"title": "Model Context Protocol",
"url": "https://en.wikipedia.org/wiki/Model_Context_Protocol",
"summary": "The Model Context Protocol (MCP) is an open standard and open-source framework introduced by Anthropic in November 2024 to standardize the way artificial intelligence (AI) systems like large language models (LLMs) integrate and share data with external tools, systems, and data sources. MCP provides a standardized interface for reading files, executing functions, and handling contextual prompts. Following its announcement, the protocol was adopted by major AI providers, including OpenAI and Google DeepMind.",
"language": "en",
"search_results": [
"Model Context Protocol",
"Agent harness",
"AI agent"
]
},
"metadata": {
"query": "Model Context Protocol",
"language": "en"
}
},
"elapsed_seconds": 11.916
}
@@ -0,0 +1,64 @@
{
"case": "arxiv_search",
"tool": "arxiv_search",
"arguments": {
"query": "agentic artificial intelligence",
"max_results": 2,
"sort_by": "relevance"
},
"arguments_sha256": "7c091475ba1290dac931ea59515b34385cafe55fcafc0018f292c7d05d7152c1",
"transport": "mcp-stdio",
"mcp_result_is_error": false,
"success": true,
"substantive_observation": true,
"backend_provenance": {
"backend": "export.arxiv.org",
"origin": "live-api"
},
"simulation_markers": [],
"error_type": null,
"payload": {
"success": true,
"message": {
"query": "agentic artificial intelligence",
"papers": [
{
"title": "Creative Problem Solving in Artificially Intelligent Agents: A Survey and Framework",
"authors": [
"Evana Gizzi",
"Lakshmi Nair",
"Sonia Chernova",
"Jivko Sinapov"
],
"summary": "Creative Problem Solving (CPS) is a sub-area within Artificial Intelligence (AI) that focuses on methods for solving off-nominal, or anomalous problems in autonomous systems. Despite many advancements in planning and learning, resolving novel problems or adapting existing knowledge to a new context, especially in cases where the environment may change in unpredictable ways post deployment, remains a limiting factor in the safe and useful integration of intelligent systems. The emergence of incre...",
"published": "2022-04-21T18:31:44+00:00",
"url": "http://arxiv.org/abs/2204.10358v1",
"pdf_url": "https://arxiv.org/pdf/2204.10358v1",
"categories": [
"cs.AI"
]
},
{
"title": "The Artificial Scientist: Logicist, Emergentist, and Universalist Approaches to Artificial General Intelligence",
"authors": [
"Michael Timothy Bennett",
"Yoshihiro Maruyama"
],
"summary": "We attempt to define what is necessary to construct an Artificial Scientist, explore and evaluate several approaches to artificial general intelligence (AGI) which may facilitate this, conclude that a unified or hybrid approach is necessary and explore two theories that satisfy this requirement to some degree....",
"published": "2021-10-05T05:58:23+00:00",
"url": "http://arxiv.org/abs/2110.01831v1",
"pdf_url": "https://arxiv.org/pdf/2110.01831v1",
"categories": [
"cs.AI"
]
}
],
"count": 2
},
"metadata": {
"query": "agentic artificial intelligence",
"max_results": 2
}
},
"elapsed_seconds": 3.126
}

Some files were not shown because too many files have changed in this diff Show More