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
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:
@@ -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/**
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)**
|
||||
@@ -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
|
||||
@@ -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 提供多种感知与数据获取能力的综合 MCP(Model 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
|
||||
- **地点搜索**:Nominatim(OpenStreetMap)
|
||||
- **POI 搜索**:Overpass API(OpenStreetMap)
|
||||
- **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`
|
||||
Nominatim(OpenStreetMap)地点搜索(免费,无需 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。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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》第四章「感知工具」的五类场景组织:
|
||||
搜索、多模态理解、文件系统、公开数据源、私有数据源。
|
||||
|
||||
设计说明:
|
||||
- 每个工具都是异步函数,返回统一的 ActionResponse(JSON)。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",
|
||||
"提取 Word(DOCX)文档内容"),
|
||||
Tool("pptx_extract", "multimodal", "document_processing_tools", "extract_pptx_content",
|
||||
"提取 PowerPoint(PPTX)内容"),
|
||||
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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
@@ -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())
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
+3793
File diff suppressed because it is too large
Load Diff
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+68
@@ -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
|
||||
BIN
Binary file not shown.
+201
@@ -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 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 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 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 Internet’s 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>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# Experiment 4-1
|
||||
|
||||
PERCEPTION-EXPERIMENT-4-1-VERIFIED
|
||||
The Model Context Protocol connects agents to perception tools.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
PERCEPTION-EXPERIMENT-4-1-VERIFIED
|
||||
+1
@@ -0,0 +1 @@
|
||||
/Users/boj/book/ai-agent-book/chapter4/perception-tools/validation/experiment_4_1/real_mcp_20260729T214301Z/fixtures/outside-witness.txt
|
||||
+1
@@ -0,0 +1 @@
|
||||
directory browse fixture
|
||||
+1
@@ -0,0 +1 @@
|
||||
PERCEPTION-EXPERIMENT-4-1-VERIFIED
|
||||
+1
@@ -0,0 +1 @@
|
||||
OUTSIDE-WITNESS-MUST-REMAIN
|
||||
+282
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+71
@@ -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
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
+64
@@ -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
Reference in New Issue
Block a user