Fix middleware schema caching error #7
36 changed files with 780 additions and 132 deletions
|
|
@ -54,7 +54,8 @@ MONITORING_ENABLED=true
|
||||||
MONITORING_INTERVAL_HOURS=24
|
MONITORING_INTERVAL_HOURS=24
|
||||||
|
|
||||||
# Directory to store schema cache (persisted between restarts)
|
# Directory to store schema cache (persisted between restarts)
|
||||||
SCHEMA_CACHE_DIR=/app/data/schemas
|
# Default: ./data/schemas (local dev) or /app/data/schemas (Docker)
|
||||||
|
SCHEMA_CACHE_DIR=./data/schemas
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
|
|
|
||||||
581
DEVELOPMENT.md
Normal file
581
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
# Development Guide
|
||||||
|
|
||||||
|
This guide covers local development setup, testing, and debugging the GeoGuessr MCP Server.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Running Locally](#running-locally)
|
||||||
|
- [Testing with MCP Inspector](#testing-with-mcp-inspector)
|
||||||
|
- [Testing with Claude Desktop](#testing-with-claude-desktop)
|
||||||
|
- [Running Tests](#running-tests)
|
||||||
|
- [Code Quality](#code-quality)
|
||||||
|
- [Debugging](#debugging)
|
||||||
|
- [Common Issues](#common-issues)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11 or higher
|
||||||
|
- `uv` (recommended) or `pip`
|
||||||
|
- Git
|
||||||
|
- A GeoGuessr account (for authenticated endpoints)
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/NyxiumYuuki/GeoGuessrMCP.git
|
||||||
|
cd GeoGuessrMCP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Virtual Environment
|
||||||
|
|
||||||
|
**Using uv (recommended):**
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate # On Linux/macOS
|
||||||
|
# OR
|
||||||
|
.venv\Scripts\activate # On Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using venv:**
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # On Linux/macOS
|
||||||
|
# OR
|
||||||
|
.venv\Scripts\activate # On Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install Dependencies
|
||||||
|
|
||||||
|
**Development dependencies (includes testing tools):**
|
||||||
|
```bash
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
# OR
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production dependencies only:**
|
||||||
|
```bash
|
||||||
|
uv pip install -e .
|
||||||
|
# OR
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and configure:
|
||||||
|
|
||||||
|
**Required for authenticated endpoints:**
|
||||||
|
```bash
|
||||||
|
GEOGUESSR_NCFA_COOKIE=your_cookie_here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional development settings:**
|
||||||
|
```bash
|
||||||
|
# Disable authentication for local testing
|
||||||
|
MCP_AUTH_ENABLED=false
|
||||||
|
|
||||||
|
# Enable debug logging
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
# Local schema cache
|
||||||
|
SCHEMA_CACHE_DIR=./data/schemas
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
MCP_HOST=0.0.0.0
|
||||||
|
MCP_PORT=8000
|
||||||
|
MCP_TRANSPORT=streamable-http
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
### Start the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m src.geoguessr_mcp.main
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://0.0.0.0:8000` by default.
|
||||||
|
|
||||||
|
**Expected output:**
|
||||||
|
```
|
||||||
|
INFO - Starting GeoGuessr MCP Server on 0.0.0.0:8000 with streamable-http transport
|
||||||
|
INFO - MCP server authentication is DISABLED - server is publicly accessible
|
||||||
|
INFO - Started server process [12345]
|
||||||
|
INFO - Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Debug Logging
|
||||||
|
|
||||||
|
Set `LOG_LEVEL=DEBUG` in `.env` to see detailed request/response logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables:
|
||||||
|
- Request method and path logging
|
||||||
|
- Request headers logging
|
||||||
|
- Authentication flow details
|
||||||
|
- MCP protocol messages
|
||||||
|
- Schema detection changes
|
||||||
|
|
||||||
|
## Testing with MCP Inspector
|
||||||
|
|
||||||
|
**MCP Inspector** is a web-based tool for testing MCP servers interactively. It's the recommended way to develop and debug MCP servers.
|
||||||
|
|
||||||
|
### 1. Install MCP Inspector
|
||||||
|
|
||||||
|
Download from: [https://github.com/modelcontextprotocol/inspector](https://github.com/modelcontextprotocol/inspector)
|
||||||
|
|
||||||
|
Or run with npx:
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP Inspector will start on `http://localhost:6274` by default.
|
||||||
|
|
||||||
|
### 2. Configure Connection
|
||||||
|
|
||||||
|
#### Without Authentication
|
||||||
|
|
||||||
|
In MCP Inspector:
|
||||||
|
1. Select **"Server-Sent Events (SSE)"** or **"Streamable HTTP"** transport
|
||||||
|
2. Set URL: `http://localhost:8000/mcp`
|
||||||
|
3. Click **"Connect"**
|
||||||
|
|
||||||
|
#### With Authentication
|
||||||
|
|
||||||
|
In MCP Inspector:
|
||||||
|
1. Select **"Streamable HTTP"** transport
|
||||||
|
2. Set URL: `http://localhost:8000/mcp`
|
||||||
|
3. Click **"Custom Headers"**
|
||||||
|
4. Add header:
|
||||||
|
- **Name:** `Authorization`
|
||||||
|
- **Value:** `Bearer YOUR_API_KEY`
|
||||||
|
5. Click **"Connect"**
|
||||||
|
|
||||||
|
**Generate API key:**
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```bash
|
||||||
|
MCP_AUTH_ENABLED=true
|
||||||
|
MCP_API_KEYS=your-generated-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test the Connection
|
||||||
|
|
||||||
|
After connecting, you should see:
|
||||||
|
- ✅ **Connection Status:** Connected
|
||||||
|
- ✅ **Protocol Version:** 2025-06-18
|
||||||
|
- ✅ **Server Info:** GeoGuessr MCP v1.22.0
|
||||||
|
|
||||||
|
### 4. Explore Available Tools
|
||||||
|
|
||||||
|
In the **"Tools"** tab, you should see:
|
||||||
|
|
||||||
|
**Authentication Tools:**
|
||||||
|
- `login(email, password)` - Login with GeoGuessr credentials
|
||||||
|
- `logout()` - Logout current session
|
||||||
|
- `set_ncfa_cookie(cookie)` - Set authentication cookie manually
|
||||||
|
- `get_current_session_token()` - Get current session status
|
||||||
|
|
||||||
|
**Profile Tools:**
|
||||||
|
- `get_profile(username)` - Get user profile
|
||||||
|
- `get_current_user_profile()` - Get your profile
|
||||||
|
- `get_user_stats(user_id)` - Get detailed stats
|
||||||
|
|
||||||
|
**Game Tools:**
|
||||||
|
- `get_game(game_id)` - Get game details
|
||||||
|
- `get_recent_games(count)` - Get recent games
|
||||||
|
- `get_activity_feed(count)` - Get activity feed
|
||||||
|
|
||||||
|
**Analysis Tools:**
|
||||||
|
- `get_performance_summary()` - Comprehensive performance overview
|
||||||
|
- `analyze_recent_games(count)` - Analyze recent gameplay
|
||||||
|
- `get_strategy_recommendations()` - Get improvement tips
|
||||||
|
|
||||||
|
**Monitoring Tools:**
|
||||||
|
- `check_api_status()` - Check API endpoint availability
|
||||||
|
- `list_available_endpoints()` - List all monitored endpoints
|
||||||
|
- `get_endpoint_schema(path)` - Get schema for specific endpoint
|
||||||
|
- `explore_endpoint(path)` - Manually explore new endpoints
|
||||||
|
|
||||||
|
### 5. Test a Tool
|
||||||
|
|
||||||
|
Try calling a simple tool:
|
||||||
|
|
||||||
|
1. Select `get_current_session_token()` from the tools list
|
||||||
|
2. Click **"Call Tool"**
|
||||||
|
3. Check the response
|
||||||
|
|
||||||
|
**Expected response (not authenticated):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"has_token": false,
|
||||||
|
"message": "No authentication cookie set. Use login() or set_ncfa_cookie()."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response (authenticated):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"has_token": true,
|
||||||
|
"user_id": "your-user-id",
|
||||||
|
"username": "your-username"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Debugging Connection Issues
|
||||||
|
|
||||||
|
If you see errors in MCP Inspector, check the server logs:
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
|
||||||
|
**❌ OPTIONS 405 Method Not Allowed**
|
||||||
|
- **Cause:** CORS middleware not configured
|
||||||
|
- **Fix:** Ensure middleware is properly applied (should be fixed in latest version)
|
||||||
|
|
||||||
|
**❌ POST 401 Unauthorized**
|
||||||
|
- **Cause:** Missing or invalid API key
|
||||||
|
- **Fix:** Add correct `Authorization: Bearer YOUR_KEY` header
|
||||||
|
|
||||||
|
**❌ POST 400 Bad Request**
|
||||||
|
- **Cause:** Session ID not maintained (CORS headers not exposed)
|
||||||
|
- **Fix:** Ensure `expose_headers` includes `mcp-session-id` (should be fixed in latest version)
|
||||||
|
|
||||||
|
**❌ Connection timeout**
|
||||||
|
- **Cause:** Server not running or firewall blocking
|
||||||
|
- **Fix:** Check server is running on `localhost:8000`
|
||||||
|
|
||||||
|
## Testing with Claude Desktop
|
||||||
|
|
||||||
|
For testing the full MCP integration with Claude Desktop:
|
||||||
|
|
||||||
|
### 1. Configure Claude Desktop
|
||||||
|
|
||||||
|
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
|
||||||
|
**Without authentication:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"geoguessr-local": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:8000/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With authentication:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"geoguessr-local": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:8000/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Restart Claude Desktop
|
||||||
|
|
||||||
|
After modifying the config, completely quit and restart Claude Desktop.
|
||||||
|
|
||||||
|
### 3. Test in Claude
|
||||||
|
|
||||||
|
In a new conversation:
|
||||||
|
|
||||||
|
```
|
||||||
|
Can you check my GeoGuessr profile?
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude should automatically use the MCP server tools to fetch your profile.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest src/tests/unit/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
**Requires authentication:**
|
||||||
|
```bash
|
||||||
|
# Set GEOGUESSR_NCFA_COOKIE in .env first
|
||||||
|
pytest src/tests/integration/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### All Tests with Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=src/geoguessr_mcp src/tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=src/geoguessr_mcp --cov-report=html src/tests/
|
||||||
|
open htmlcov/index.html # View coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
### Format Code with Black
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black src/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:** Line length 100 (see `pyproject.toml`)
|
||||||
|
|
||||||
|
### Lint with Ruff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check src/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-fix:**
|
||||||
|
```bash
|
||||||
|
ruff check --fix src/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Check with MyPy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mypy src/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
Install pre-commit hooks to automatically run checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
This will run Black, Ruff, and MyPy before each commit.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Enable Debug Mode
|
||||||
|
|
||||||
|
Set in `.env`:
|
||||||
|
```bash
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the server to see:
|
||||||
|
- All HTTP requests with headers
|
||||||
|
- Authentication flow
|
||||||
|
- MCP protocol messages
|
||||||
|
- Schema detection and changes
|
||||||
|
|
||||||
|
### Debug Authentication
|
||||||
|
|
||||||
|
Check if your GeoGuessr cookie is valid:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Python console
|
||||||
|
from src.geoguessr_mcp.auth import session_manager
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
token = session_manager.get_current_session_token()
|
||||||
|
print(f"Token: {token}")
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug API Calls
|
||||||
|
|
||||||
|
Test API endpoints directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install httpx
|
||||||
|
pip install httpx
|
||||||
|
|
||||||
|
# Test endpoint
|
||||||
|
python -c "
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
'https://www.geoguessr.com/api/v3/profiles',
|
||||||
|
cookies={'_ncfa': 'YOUR_COOKIE'}
|
||||||
|
)
|
||||||
|
print(response.json())
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Schema Detection
|
||||||
|
|
||||||
|
Check schema cache:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la data/schemas/
|
||||||
|
cat data/schemas/schemas.json | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Force schema refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf data/schemas/*
|
||||||
|
# Restart server - schemas will be regenerated
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Server Logs
|
||||||
|
|
||||||
|
**Real-time logs:**
|
||||||
|
```bash
|
||||||
|
tail -f server.log # If using file logging
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker logs:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f geoguessr-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Issue: Module not found errors
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Ensure you're in the project root
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Permission denied on schema cache
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
mkdir -p data/schemas
|
||||||
|
chmod 755 data/schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a different path in `.env`:
|
||||||
|
```bash
|
||||||
|
SCHEMA_CACHE_DIR=/tmp/geoguessr-schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Port 8000 already in use
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Use different port
|
||||||
|
echo "MCP_PORT=8001" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Or kill the process using port 8000:
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
lsof -ti:8000 | xargs kill -9
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :8000
|
||||||
|
taskkill /PID <PID> /F
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: CORS errors in browser
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Ensure CORS middleware is properly configured with `expose_headers`:
|
||||||
|
```python
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Session not maintained (400 errors)
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- First POST succeeds (200 OK)
|
||||||
|
- Second POST fails (400 Bad Request)
|
||||||
|
- Logs show new session created each time
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
The `mcp-session-id` header must be exposed via CORS (fixed in latest version).
|
||||||
|
|
||||||
|
Check server logs for:
|
||||||
|
```
|
||||||
|
expose_headers=["mcp-session-id", "mcp-protocol-version"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. Create a feature branch
|
||||||
|
2. Make changes
|
||||||
|
3. Run tests: `pytest`
|
||||||
|
4. Run code quality checks: `black`, `ruff`, `mypy`
|
||||||
|
5. Commit with descriptive message
|
||||||
|
6. Push and create pull request
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- **Formatting:** Black (line length 100)
|
||||||
|
- **Linting:** Ruff
|
||||||
|
- **Type hints:** Required for all functions
|
||||||
|
- **Docstrings:** Google style for public APIs
|
||||||
|
- **Tests:** Required for new features
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/geoguessr_mcp/
|
||||||
|
├── api/ # GeoGuessr API client
|
||||||
|
├── auth/ # Authentication & session management
|
||||||
|
├── middleware/ # MCP server middleware (auth, CORS)
|
||||||
|
├── models/ # Data models (Profile, Stats, Games)
|
||||||
|
├── monitoring/ # API monitoring & schema detection
|
||||||
|
│ ├── endpoint/ # Endpoint monitoring
|
||||||
|
│ └── schema/ # Schema detection & registry
|
||||||
|
├── services/ # Business logic services
|
||||||
|
├── tools/ # MCP tool definitions
|
||||||
|
├── config.py # Configuration management
|
||||||
|
└── main.py # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [MCP Documentation](https://modelcontextprotocol.io/)
|
||||||
|
- [FastMCP Guide](https://github.com/jlowin/fastmcp)
|
||||||
|
- [MCP Inspector](https://github.com/modelcontextprotocol/inspector)
|
||||||
|
- [GeoGuessr API (unofficial)](https://www.geoguessr.com/api)
|
||||||
|
- [Starlette Middleware](https://www.starlette.io/middleware/)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- Check this guide first
|
||||||
|
- Review server logs with `LOG_LEVEL=DEBUG`
|
||||||
|
- Check existing issues on GitHub
|
||||||
|
- Create a new issue with logs and reproduction steps
|
||||||
|
|
@ -4,8 +4,8 @@ GeoGuessr API Endpoints Registry.
|
||||||
Centralized endpoint definitions with metadata for dynamic discovery and routing.
|
Centralized endpoint definitions with metadata for dynamic discovery and routing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ class EndpointInfo:
|
||||||
description: str = ""
|
description: str = ""
|
||||||
auth_required: bool = True
|
auth_required: bool = True
|
||||||
use_game_server: bool = False
|
use_game_server: bool = False
|
||||||
params_builder: Optional[Callable[..., dict]] = None
|
params_builder: Callable[..., dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Endpoints:
|
class Endpoints:
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,15 @@ Classes:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .dynamic_response import DynamicResponse
|
|
||||||
from .endpoints import EndpointInfo
|
|
||||||
from ..auth import get_current_user_context
|
from ..auth import get_current_user_context
|
||||||
from ..auth.session import SessionManager
|
from ..auth.session import SessionManager
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..monitoring.schema.schema_registry import schema_registry
|
from ..monitoring.schema.schema_registry import schema_registry
|
||||||
|
from .dynamic_response import DynamicResponse
|
||||||
|
from .endpoints import EndpointInfo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -45,7 +44,7 @@ class GeoGuessrClient:
|
||||||
|
|
||||||
async def _get_authenticated_client(
|
async def _get_authenticated_client(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> httpx.AsyncClient:
|
) -> httpx.AsyncClient:
|
||||||
"""
|
"""
|
||||||
Get an authenticated HTTP client.
|
Get an authenticated HTTP client.
|
||||||
|
|
@ -79,9 +78,9 @@ class GeoGuessrClient:
|
||||||
async def request(
|
async def request(
|
||||||
self,
|
self,
|
||||||
endpoint: EndpointInfo,
|
endpoint: EndpointInfo,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
params: Optional[dict] = None,
|
params: dict | None = None,
|
||||||
json_data: Optional[dict] = None,
|
json_data: dict | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""
|
"""
|
||||||
|
|
@ -154,8 +153,8 @@ class GeoGuessrClient:
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
endpoint: EndpointInfo,
|
endpoint: EndpointInfo,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
params: Optional[dict] = None,
|
params: dict | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Make a GET request."""
|
"""Make a GET request."""
|
||||||
|
|
@ -164,8 +163,8 @@ class GeoGuessrClient:
|
||||||
async def post(
|
async def post(
|
||||||
self,
|
self,
|
||||||
endpoint: EndpointInfo,
|
endpoint: EndpointInfo,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
json_data: Optional[dict] = None,
|
json_data: dict | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Make a POST request."""
|
"""Make a POST request."""
|
||||||
|
|
@ -174,9 +173,9 @@ class GeoGuessrClient:
|
||||||
async def get_raw(
|
async def get_raw(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
use_game_server: bool = False,
|
use_game_server: bool = False,
|
||||||
params: Optional[dict] = None,
|
params: dict | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""
|
"""
|
||||||
Make a raw GET request to any path.
|
Make a raw GET request to any path.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ where each API key can have its own GeoGuessr session.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from .session import SessionManager, UserSession
|
from .session import SessionManager, UserSession
|
||||||
|
|
@ -146,7 +145,7 @@ class MultiUserSessionManager:
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def get_session_for_api_key(self, api_key: str) -> Optional[UserSession]:
|
async def get_session_for_api_key(self, api_key: str) -> UserSession | None:
|
||||||
"""
|
"""
|
||||||
Get the active session for a specific API key.
|
Get the active session for a specific API key.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@ the authenticated user making the request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .user_context import UserContext
|
from .user_context import UserContext
|
||||||
|
|
||||||
# Context variable to store the current user context
|
# Context variable to store the current user context
|
||||||
_current_user_context: ContextVar[Optional[UserContext]] = ContextVar(
|
_current_user_context: ContextVar[UserContext | None] = ContextVar(
|
||||||
"current_user_context", default=None
|
"current_user_context", default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,7 +29,7 @@ def set_current_user_context(context: UserContext) -> None:
|
||||||
_current_user_context.set(context)
|
_current_user_context.set(context)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_context() -> Optional[UserContext]:
|
def get_current_user_context() -> UserContext | None:
|
||||||
"""
|
"""
|
||||||
Get the current user context.
|
Get the current user context.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import logging
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -25,7 +24,7 @@ class UserSession:
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
expires_at: Optional[datetime] = None
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
"""Check if the session is still valid."""
|
"""Check if the session is still valid."""
|
||||||
|
|
@ -37,10 +36,10 @@ class UserSession:
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manages user sessions for the MCP server."""
|
"""Manages user sessions for the MCP server."""
|
||||||
|
|
||||||
def __init__(self, default_cookie: Optional[str] = None):
|
def __init__(self, default_cookie: str | None = None):
|
||||||
self._sessions: dict[str, UserSession] = {}
|
self._sessions: dict[str, UserSession] = {}
|
||||||
self._user_sessions: dict[str, str] = {}
|
self._user_sessions: dict[str, str] = {}
|
||||||
self._default_cookie: Optional[str] = default_cookie or settings.DEFAULT_NCFA_COOKIE
|
self._default_cookie: str | None = default_cookie or settings.DEFAULT_NCFA_COOKIE
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -111,7 +110,7 @@ class SessionManager:
|
||||||
return session_token, session
|
return session_token, session
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_ncfa_cookie(response: httpx.Response) -> Optional[str]:
|
def _extract_ncfa_cookie(response: httpx.Response) -> str | None:
|
||||||
"""Extract _ncfa cookie from response."""
|
"""Extract _ncfa cookie from response."""
|
||||||
# Try cookies jar first
|
# Try cookies jar first
|
||||||
for cookie in response.cookies.jar:
|
for cookie in response.cookies.jar:
|
||||||
|
|
@ -160,7 +159,7 @@ class SessionManager:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_session(self, session_token: Optional[str] = None) -> Optional[UserSession]:
|
async def get_session(self, session_token: str | None = None) -> UserSession | None:
|
||||||
"""
|
"""
|
||||||
Get a session by token or return default if available.
|
Get a session by token or return default if available.
|
||||||
|
|
||||||
|
|
@ -203,7 +202,7 @@ class SessionManager:
|
||||||
logger.info("Default NCFA cookie updated")
|
logger.info("Default NCFA cookie updated")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def validate_cookie(cookie: str) -> Optional[dict]:
|
async def validate_cookie(cookie: str) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Validate a cookie by making a test request.
|
Validate a cookie by making a test request.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ is making a request and their associated GeoGuessr session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .session import UserSession
|
from .session import UserSession
|
||||||
|
|
||||||
|
|
@ -23,7 +22,7 @@ class UserContext:
|
||||||
api_key: str
|
api_key: str
|
||||||
"""The API key used to authenticate this request"""
|
"""The API key used to authenticate this request"""
|
||||||
|
|
||||||
session: Optional[UserSession] = None
|
session: UserSession | None = None
|
||||||
"""The GeoGuessr session for this user (if logged in)"""
|
"""The GeoGuessr session for this user (if logged in)"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -41,7 +40,7 @@ class UserContext:
|
||||||
return f"User-{hash(self.api_key) % 10000:04d}"
|
return f"User-{hash(self.api_key) % 10000:04d}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ncfa_cookie(self) -> Optional[str]:
|
def ncfa_cookie(self) -> str | None:
|
||||||
"""Get the NCFA cookie for this user."""
|
"""Get the NCFA cookie for this user."""
|
||||||
if self.session:
|
if self.session:
|
||||||
return self.session.ncfa_cookie
|
return self.session.ncfa_cookie
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -18,7 +17,7 @@ class Settings:
|
||||||
GEOGUESSR_DOMAIN_NAME: str = "geoguessr.com"
|
GEOGUESSR_DOMAIN_NAME: str = "geoguessr.com"
|
||||||
GEOGUESSR_API_URL: str = "https://www.geoguessr.com/api"
|
GEOGUESSR_API_URL: str = "https://www.geoguessr.com/api"
|
||||||
GAME_SERVER_URL: str = "https://game-server.geoguessr.com/api"
|
GAME_SERVER_URL: str = "https://game-server.geoguessr.com/api"
|
||||||
DEFAULT_NCFA_COOKIE: Optional[str] = field(
|
DEFAULT_NCFA_COOKIE: str | None = field(
|
||||||
default_factory=lambda: os.getenv("GEOGUESSR_NCFA_COOKIE")
|
default_factory=lambda: os.getenv("GEOGUESSR_NCFA_COOKIE")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,14 +29,14 @@ class Settings:
|
||||||
default_factory=lambda: int(os.getenv("MONITORING_INTERVAL_HOURS", "24"))
|
default_factory=lambda: int(os.getenv("MONITORING_INTERVAL_HOURS", "24"))
|
||||||
)
|
)
|
||||||
SCHEMA_CACHE_DIR: str = field(
|
SCHEMA_CACHE_DIR: str = field(
|
||||||
default_factory=lambda: os.getenv("SCHEMA_CACHE_DIR", "/app/data/schemas")
|
default_factory=lambda: os.getenv("SCHEMA_CACHE_DIR", "./data/schemas")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Authentication Configuration
|
# Authentication Configuration
|
||||||
MCP_AUTH_ENABLED: bool = field(
|
MCP_AUTH_ENABLED: bool = field(
|
||||||
default_factory=lambda: os.getenv("MCP_AUTH_ENABLED", "false").lower() == "true"
|
default_factory=lambda: os.getenv("MCP_AUTH_ENABLED", "false").lower() == "true"
|
||||||
)
|
)
|
||||||
MCP_API_KEYS: Optional[str] = field(default_factory=lambda: os.getenv("MCP_API_KEYS"))
|
MCP_API_KEYS: str | None = field(default_factory=lambda: os.getenv("MCP_API_KEYS"))
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
LOG_LEVEL: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO"))
|
LOG_LEVEL: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO"))
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .middleware import AuthenticationMiddleware
|
from .middleware import AuthenticationMiddleware
|
||||||
|
|
@ -21,16 +23,33 @@ logging.basicConfig(
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Log request details for debugging."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
"""Log request and response details."""
|
||||||
|
logger.debug(f"Request: {request.method} {request.url.path}")
|
||||||
|
logger.debug(f"Headers: {dict(request.headers)}")
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.warning(
|
||||||
|
f"Error response: {request.method} {request.url.path} -> {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point for the server."""
|
"""Main entry point for the server."""
|
||||||
|
|
||||||
# Create the MCP server instance
|
# Create the MCP server instance
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"GeoGuessr Analyzer",
|
"GeoGuessr MCP",
|
||||||
instructions="""
|
instructions="""
|
||||||
MCP server for analyzing GeoGuessr game statistics and optimizing gameplay strategy.
|
MCP server for analyzing GeoGuessr game statistics and optimizing gameplay strategy.
|
||||||
|
|
||||||
|
|
@ -62,22 +81,62 @@ def main():
|
||||||
# Register all tools
|
# Register all tools
|
||||||
register_all_tools(mcp)
|
register_all_tools(mcp)
|
||||||
|
|
||||||
# Setup authentication middleware if enabled
|
# Wrap the streamable_http_app method to inject middleware
|
||||||
if settings.MCP_AUTH_ENABLED:
|
_original_streamable_http_app = mcp.streamable_http_app
|
||||||
logger.info("Setting up authentication middleware")
|
|
||||||
|
|
||||||
# Récupérez l'application ASGI via streamable_http_app
|
def _streamable_http_app_with_middleware():
|
||||||
mcp_app = mcp.streamable_http_app()
|
"""Wrap app creation to inject middleware."""
|
||||||
|
app = _original_streamable_http_app()
|
||||||
|
|
||||||
# Ajoutez les middlewares
|
# Add request logging middleware for debugging (first in chain)
|
||||||
mcp_app.add_middleware(
|
if settings.LOG_LEVEL == "DEBUG":
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
|
||||||
|
# Always add CORS middleware for browser compatibility
|
||||||
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||||
)
|
)
|
||||||
mcp_app.add_middleware(AuthenticationMiddleware)
|
|
||||||
|
# Add authentication middleware if enabled
|
||||||
|
if settings.MCP_AUTH_ENABLED:
|
||||||
|
app.add_middleware(AuthenticationMiddleware)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
# Replace the method with our wrapper
|
||||||
|
mcp.streamable_http_app = _streamable_http_app_with_middleware
|
||||||
|
|
||||||
|
# Also wrap sse_app for SSE transport
|
||||||
|
if hasattr(mcp, "sse_app"):
|
||||||
|
_original_sse_app = mcp.sse_app
|
||||||
|
|
||||||
|
def _sse_app_with_middleware():
|
||||||
|
"""Wrap SSE app creation to inject middleware."""
|
||||||
|
app = _original_sse_app()
|
||||||
|
|
||||||
|
if settings.LOG_LEVEL == "DEBUG":
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.MCP_AUTH_ENABLED:
|
||||||
|
app.add_middleware(AuthenticationMiddleware)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
mcp.sse_app = _sse_app_with_middleware
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting GeoGuessr MCP Server on {settings.HOST}:{settings.PORT} "
|
f"Starting GeoGuessr MCP Server on {settings.HOST}:{settings.PORT} "
|
||||||
|
|
@ -98,7 +157,7 @@ def main():
|
||||||
"Users will need to login or provide a cookie."
|
"Users will need to login or provide a cookie."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run the server
|
# Run the server - middleware will be applied via our wrapper
|
||||||
mcp.run(transport=settings.TRANSPORT)
|
mcp.run(transport=settings.TRANSPORT)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ and attaches user context for multi-user support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
@ -26,7 +25,7 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
Authorization header with a Bearer token matching one of the configured API keys.
|
Authorization header with a Bearer token matching one of the configured API keys.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, valid_api_keys: Optional[set[str]] = None):
|
def __init__(self, app, valid_api_keys: set[str] | None = None):
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
self.valid_api_keys = valid_api_keys or settings.get_api_keys()
|
self.valid_api_keys = valid_api_keys or settings.get_api_keys()
|
||||||
self.enabled = settings.MCP_AUTH_ENABLED
|
self.enabled = settings.MCP_AUTH_ENABLED
|
||||||
|
|
@ -54,6 +53,11 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
if request.url.path == "/health":
|
if request.url.path == "/health":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Skip authentication for OPTIONS requests (CORS preflight)
|
||||||
|
# OPTIONS requests don't include Authorization headers by design
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# Check for Authorization header
|
# Check for Authorization header
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Achievement-related data models."""
|
"""Achievement-related data models."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -12,9 +11,9 @@ class Achievement:
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
unlocked: bool = False
|
unlocked: bool = False
|
||||||
unlocked_at: Optional[str] = None
|
unlocked_at: str | None = None
|
||||||
progress: float = 0.0
|
progress: float = 0.0
|
||||||
icon_url: Optional[str] = None
|
icon_url: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(cls, data: dict) -> "Achievement":
|
def from_api_response(cls, data: dict) -> "Achievement":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""DailyChallenge-related data models."""
|
"""DailyChallenge-related data models."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -13,7 +12,7 @@ class DailyChallenge:
|
||||||
date: str = ""
|
date: str = ""
|
||||||
time_limit: int = 0
|
time_limit: int = 0
|
||||||
completed: bool = False
|
completed: bool = False
|
||||||
score: Optional[int] = None
|
score: int | None = None
|
||||||
raw_data: dict = field(default_factory=dict)
|
raw_data: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Game-related data models."""
|
"""Game-related data models."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .round_guess import RoundGuess
|
from .round_guess import RoundGuess
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@ class Game:
|
||||||
mode: str
|
mode: str
|
||||||
total_score: int
|
total_score: int
|
||||||
rounds: list[RoundGuess] = field(default_factory=list)
|
rounds: list[RoundGuess] = field(default_factory=list)
|
||||||
created_at: Optional[str] = None
|
created_at: str | None = None
|
||||||
finished: bool = False
|
finished: bool = False
|
||||||
raw_data: dict = field(default_factory=dict)
|
raw_data: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""RoundGuess-related data models."""
|
"""RoundGuess-related data models."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -12,8 +11,8 @@ class RoundGuess:
|
||||||
score: int
|
score: int
|
||||||
distance_meters: float
|
distance_meters: float
|
||||||
time_seconds: int
|
time_seconds: int
|
||||||
lat: Optional[float] = None
|
lat: float | None = None
|
||||||
lng: Optional[float] = None
|
lng: float | None = None
|
||||||
country: str = ""
|
country: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""UserProfile-related data models."""
|
"""UserProfile-related data models."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -16,7 +15,7 @@ class UserProfile:
|
||||||
created: str = ""
|
created: str = ""
|
||||||
is_verified: bool = False
|
is_verified: bool = False
|
||||||
is_pro: bool = False
|
is_pro: bool = False
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: str | None = None
|
||||||
raw_data: dict = field(default_factory=dict)
|
raw_data: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,16 @@ Classes:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ...config import settings
|
||||||
|
from ..schema.schema_registry import SchemaRegistry, schema_registry
|
||||||
from .endpoint_definition import EndpointDefinition
|
from .endpoint_definition import EndpointDefinition
|
||||||
from .endpoint_monitoring_result import MonitoringResult
|
from .endpoint_monitoring_result import MonitoringResult
|
||||||
from ..schema.schema_registry import SchemaRegistry, schema_registry
|
|
||||||
from ...config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -119,14 +119,14 @@ class EndpointMonitor:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
registry: Optional[SchemaRegistry] = None,
|
registry: SchemaRegistry | None = None,
|
||||||
ncfa_cookie: Optional[str] = None,
|
ncfa_cookie: str | None = None,
|
||||||
):
|
):
|
||||||
self.registry = registry or schema_registry
|
self.registry = registry or schema_registry
|
||||||
self.ncfa_cookie = ncfa_cookie or settings.DEFAULT_NCFA_COOKIE
|
self.ncfa_cookie = ncfa_cookie or settings.DEFAULT_NCFA_COOKIE
|
||||||
self.results: list[MonitoringResult] = []
|
self.results: list[MonitoringResult] = []
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
async def check_endpoint(
|
async def check_endpoint(
|
||||||
self,
|
self,
|
||||||
|
|
@ -286,10 +286,8 @@ class EndpointMonitor:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._task:
|
if self._task:
|
||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
try:
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("Stopped periodic monitoring")
|
logger.info("Stopped periodic monitoring")
|
||||||
|
|
||||||
async def _monitoring_loop(self) -> None:
|
async def _monitoring_loop(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ an endpoint, including its availability, response time, and any errors encounter
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -20,5 +19,5 @@ class MonitoringResult:
|
||||||
response_code: int
|
response_code: int
|
||||||
response_time_ms: float
|
response_time_ms: float
|
||||||
schema_changed: bool
|
schema_changed: bool
|
||||||
error_message: Optional[str] = None
|
error_message: str | None = None
|
||||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ schema information.
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from .schema_field import SchemaField
|
from .schema_field import SchemaField
|
||||||
|
|
||||||
|
|
@ -29,8 +29,8 @@ class EndpointSchema:
|
||||||
schema_hash: str = ""
|
schema_hash: str = ""
|
||||||
response_code: int = 200
|
response_code: int = 200
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
error_message: Optional[str] = None
|
error_message: str | None = None
|
||||||
sample_response: Optional[dict] = None
|
sample_response: dict | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for serialization."""
|
"""Convert to dictionary for serialization."""
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ a schema field, including its name, type, and other relevant metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -16,6 +16,6 @@ class SchemaField:
|
||||||
name: str
|
name: str
|
||||||
field_type: str
|
field_type: str
|
||||||
nullable: bool = False
|
nullable: bool = False
|
||||||
nested_schema: Optional[dict] = None
|
nested_schema: dict | None = None
|
||||||
example_value: Any = None
|
example_value: Any = None
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
|
from ...config import settings
|
||||||
from .endpoint_schema import EndpointSchema
|
from .endpoint_schema import EndpointSchema
|
||||||
from .schema_detector import SchemaDetector
|
from .schema_detector import SchemaDetector
|
||||||
from ...config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ class SchemaRegistry:
|
||||||
to track changes over time and adapt automatically.
|
to track changes over time and adapt automatically.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cache_dir: Optional[str] = None):
|
def __init__(self, cache_dir: str | None = None):
|
||||||
self.cache_dir = Path(cache_dir or settings.SCHEMA_CACHE_DIR)
|
self.cache_dir = Path(cache_dir or settings.SCHEMA_CACHE_DIR)
|
||||||
|
|
||||||
# Try to create the cache directory, fall back to temp if permission denied
|
# Try to create the cache directory, fall back to temp if permission denied
|
||||||
|
|
@ -71,6 +71,16 @@ class SchemaRegistry:
|
||||||
for endpoint, schema_data in data.items():
|
for endpoint, schema_data in data.items():
|
||||||
self.schemas[endpoint] = EndpointSchema.from_dict(schema_data)
|
self.schemas[endpoint] = EndpointSchema.from_dict(schema_data)
|
||||||
logger.info(f"Loaded {len(self.schemas)} cached schemas")
|
logger.info(f"Loaded {len(self.schemas)} cached schemas")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to load cached schemas due to corrupted JSON: {e}. "
|
||||||
|
f"Removing corrupted cache file."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
schema_file.unlink()
|
||||||
|
logger.info(f"Removed corrupted schema cache file: {schema_file}")
|
||||||
|
except Exception as rm_error:
|
||||||
|
logger.error(f"Failed to remove corrupted cache file: {rm_error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load cached schemas: {e}")
|
logger.warning(f"Failed to load cached schemas: {e}")
|
||||||
|
|
||||||
|
|
@ -83,6 +93,16 @@ class SchemaRegistry:
|
||||||
self.schema_history[endpoint] = [
|
self.schema_history[endpoint] = [
|
||||||
EndpointSchema.from_dict(h) for h in history
|
EndpointSchema.from_dict(h) for h in history
|
||||||
]
|
]
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to load schema history due to corrupted JSON: {e}. "
|
||||||
|
f"Removing corrupted history file."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
history_file.unlink()
|
||||||
|
logger.info(f"Removed corrupted schema history file: {history_file}")
|
||||||
|
except Exception as rm_error:
|
||||||
|
logger.error(f"Failed to remove corrupted history file: {rm_error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load schema history: {e}")
|
logger.warning(f"Failed to load schema history: {e}")
|
||||||
|
|
||||||
|
|
@ -167,7 +187,7 @@ class SchemaRegistry:
|
||||||
)
|
)
|
||||||
self._save_schemas()
|
self._save_schemas()
|
||||||
|
|
||||||
def get_schema(self, endpoint: str) -> Optional[EndpointSchema]:
|
def get_schema(self, endpoint: str) -> EndpointSchema | None:
|
||||||
"""Get the current schema for an endpoint."""
|
"""Get the current schema for an endpoint."""
|
||||||
return self.schemas.get(endpoint)
|
return self.schemas.get(endpoint)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,12 @@ dynamic data handling and LLM-friendly output formatting.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .game_service import GameService
|
|
||||||
from .profile_service import ProfileService
|
|
||||||
from ..api import GeoGuessrClient
|
from ..api import GeoGuessrClient
|
||||||
from ..models import Game
|
from ..models import Game
|
||||||
from ..monitoring import schema_registry
|
from ..monitoring import schema_registry
|
||||||
|
from .game_service import GameService
|
||||||
|
from .profile_service import ProfileService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -61,8 +60,8 @@ class AnalysisService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: GeoGuessrClient,
|
client: GeoGuessrClient,
|
||||||
game_service: Optional[GameService] = None,
|
game_service: GameService | None = None,
|
||||||
profile_service: Optional[ProfileService] = None,
|
profile_service: ProfileService | None = None,
|
||||||
):
|
):
|
||||||
self.client = client
|
self.client = client
|
||||||
self.game_service = game_service or GameService(client)
|
self.game_service = game_service or GameService(client)
|
||||||
|
|
@ -155,7 +154,7 @@ class AnalysisService:
|
||||||
async def analyze_recent_games(
|
async def analyze_recent_games(
|
||||||
self,
|
self,
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Analyze recent games and provide statistics summary.
|
Analyze recent games and provide statistics summary.
|
||||||
|
|
@ -181,7 +180,7 @@ class AnalysisService:
|
||||||
|
|
||||||
async def get_performance_summary(
|
async def get_performance_summary(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get a comprehensive performance summary.
|
Get a comprehensive performance summary.
|
||||||
|
|
@ -246,7 +245,7 @@ class AnalysisService:
|
||||||
|
|
||||||
async def get_strategy_recommendations(
|
async def get_strategy_recommendations(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Generate strategy recommendations based on performance analysis.
|
Generate strategy recommendations based on performance analysis.
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ Handles game history, details, and competitive data with dynamic schema support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..api import Endpoints, DynamicResponse, GeoGuessrClient
|
from ..api import DynamicResponse, Endpoints, GeoGuessrClient
|
||||||
from ..models import DailyChallenge, Game, SeasonStats
|
from ..models import DailyChallenge, Game, SeasonStats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -22,7 +21,7 @@ class GameService:
|
||||||
async def get_game_details(
|
async def get_game_details(
|
||||||
self,
|
self,
|
||||||
game_token: str,
|
game_token: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[Game, DynamicResponse]:
|
) -> tuple[Game, DynamicResponse]:
|
||||||
"""
|
"""
|
||||||
Get details for a specific game.
|
Get details for a specific game.
|
||||||
|
|
@ -45,7 +44,7 @@ class GameService:
|
||||||
|
|
||||||
async def get_unfinished_games(
|
async def get_unfinished_games(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get list of unfinished games."""
|
"""Get list of unfinished games."""
|
||||||
return await self.client.get(Endpoints.GAMES.GET_UNFINISHED_GAMES, session_token)
|
return await self.client.get(Endpoints.GAMES.GET_UNFINISHED_GAMES, session_token)
|
||||||
|
|
@ -53,7 +52,7 @@ class GameService:
|
||||||
async def get_streak_game(
|
async def get_streak_game(
|
||||||
self,
|
self,
|
||||||
game_token: str,
|
game_token: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get streak game details."""
|
"""Get streak game details."""
|
||||||
endpoint = Endpoints.GAMES.get_streak_game(game_token)
|
endpoint = Endpoints.GAMES.get_streak_game(game_token)
|
||||||
|
|
@ -63,7 +62,7 @@ class GameService:
|
||||||
self,
|
self,
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""
|
"""
|
||||||
Get the activity feed.
|
Get the activity feed.
|
||||||
|
|
@ -82,7 +81,7 @@ class GameService:
|
||||||
async def get_recent_games(
|
async def get_recent_games(
|
||||||
self,
|
self,
|
||||||
count: int = 10,
|
count: int = 10,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> list[Game]:
|
) -> list[Game]:
|
||||||
"""
|
"""
|
||||||
Get recent games from the activity feed.
|
Get recent games from the activity feed.
|
||||||
|
|
@ -122,7 +121,7 @@ class GameService:
|
||||||
|
|
||||||
async def get_season_stats(
|
async def get_season_stats(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[SeasonStats, DynamicResponse]:
|
) -> tuple[SeasonStats, DynamicResponse]:
|
||||||
"""Get active season statistics."""
|
"""Get active season statistics."""
|
||||||
response = await self.client.get(
|
response = await self.client.get(
|
||||||
|
|
@ -138,7 +137,7 @@ class GameService:
|
||||||
async def get_daily_challenge(
|
async def get_daily_challenge(
|
||||||
self,
|
self,
|
||||||
day: str = "today",
|
day: str = "today",
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[DailyChallenge, DynamicResponse]:
|
) -> tuple[DailyChallenge, DynamicResponse]:
|
||||||
"""
|
"""
|
||||||
Get daily challenge.
|
Get daily challenge.
|
||||||
|
|
@ -162,7 +161,7 @@ class GameService:
|
||||||
async def get_battle_royale(
|
async def get_battle_royale(
|
||||||
self,
|
self,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get battle royale game details."""
|
"""Get battle royale game details."""
|
||||||
endpoint = Endpoints.GAME_SERVER.get_battle_royale(game_id)
|
endpoint = Endpoints.GAME_SERVER.get_battle_royale(game_id)
|
||||||
|
|
@ -171,7 +170,7 @@ class GameService:
|
||||||
async def get_duel(
|
async def get_duel(
|
||||||
self,
|
self,
|
||||||
duel_id: str,
|
duel_id: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get duel game details."""
|
"""Get duel game details."""
|
||||||
endpoint = Endpoints.GAME_SERVER.get_duel(duel_id)
|
endpoint = Endpoints.GAME_SERVER.get_duel(duel_id)
|
||||||
|
|
@ -179,7 +178,7 @@ class GameService:
|
||||||
|
|
||||||
async def get_tournaments(
|
async def get_tournaments(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get tournament information."""
|
"""Get tournament information."""
|
||||||
return await self.client.get(Endpoints.GAME_SERVER.GET_TOURNAMENTS, session_token)
|
return await self.client.get(Endpoints.GAME_SERVER.GET_TOURNAMENTS, session_token)
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ dynamic schema adaptation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..api import DynamicResponse, GeoGuessrClient, Endpoints
|
from ..api import DynamicResponse, Endpoints, GeoGuessrClient
|
||||||
from ..models import Achievement, UserProfile, UserStats
|
from ..models import Achievement, UserProfile, UserStats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -22,7 +21,7 @@ class ProfileService:
|
||||||
|
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[UserProfile, DynamicResponse]:
|
) -> tuple[UserProfile, DynamicResponse]:
|
||||||
"""
|
"""
|
||||||
Get current user's profile.
|
Get current user's profile.
|
||||||
|
|
@ -40,7 +39,7 @@ class ProfileService:
|
||||||
|
|
||||||
async def get_stats(
|
async def get_stats(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[UserStats, DynamicResponse]:
|
) -> tuple[UserStats, DynamicResponse]:
|
||||||
"""
|
"""
|
||||||
Get user statistics.
|
Get user statistics.
|
||||||
|
|
@ -58,7 +57,7 @@ class ProfileService:
|
||||||
|
|
||||||
async def get_extended_stats(
|
async def get_extended_stats(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""
|
"""
|
||||||
Get extended statistics.
|
Get extended statistics.
|
||||||
|
|
@ -69,7 +68,7 @@ class ProfileService:
|
||||||
|
|
||||||
async def get_achievements(
|
async def get_achievements(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[list[Achievement], DynamicResponse]:
|
) -> tuple[list[Achievement], DynamicResponse]:
|
||||||
"""
|
"""
|
||||||
Get user achievements.
|
Get user achievements.
|
||||||
|
|
@ -96,7 +95,7 @@ class ProfileService:
|
||||||
async def get_public_profile(
|
async def get_public_profile(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> tuple[UserProfile, DynamicResponse]:
|
) -> tuple[UserProfile, DynamicResponse]:
|
||||||
"""Get another user's public profile."""
|
"""Get another user's public profile."""
|
||||||
endpoint = Endpoints.PROFILES.get_public_profile(user_id)
|
endpoint = Endpoints.PROFILES.get_public_profile(user_id)
|
||||||
|
|
@ -110,14 +109,14 @@ class ProfileService:
|
||||||
|
|
||||||
async def get_user_maps(
|
async def get_user_maps(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> DynamicResponse:
|
) -> DynamicResponse:
|
||||||
"""Get user's custom maps."""
|
"""Get user's custom maps."""
|
||||||
return await self.client.get(Endpoints.PROFILES.GET_USER_MAPS, session_token)
|
return await self.client.get(Endpoints.PROFILES.GET_USER_MAPS, session_token)
|
||||||
|
|
||||||
async def get_comprehensive_profile(
|
async def get_comprehensive_profile(
|
||||||
self,
|
self,
|
||||||
session_token: Optional[str] = None,
|
session_token: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get a comprehensive profile combining multiple endpoints.
|
Get a comprehensive profile combining multiple endpoints.
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .analysis_tools import register_analysis_tools
|
|
||||||
from .auth_tools import register_auth_tools
|
|
||||||
from .game_tools import register_game_tools
|
|
||||||
from .monitoring_tools import register_monitoring_tools
|
|
||||||
from .profile_tools import register_profile_tools
|
|
||||||
from ..api.geoguessr_client import GeoGuessrClient
|
from ..api.geoguessr_client import GeoGuessrClient
|
||||||
from ..auth.session import SessionManager
|
from ..auth.session import SessionManager
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..services.analysis_service import AnalysisService
|
from ..services.analysis_service import AnalysisService
|
||||||
from ..services.game_service import GameService
|
from ..services.game_service import GameService
|
||||||
from ..services.profile_service import ProfileService
|
from ..services.profile_service import ProfileService
|
||||||
|
from .analysis_tools import register_analysis_tools
|
||||||
|
from .auth_tools import register_auth_tools
|
||||||
|
from .game_tools import register_game_tools
|
||||||
|
from .monitoring_tools import register_monitoring_tools
|
||||||
|
from .profile_tools import register_profile_tools
|
||||||
|
|
||||||
|
|
||||||
def register_all_tools(mcp: FastMCP) -> dict:
|
def register_all_tools(mcp: FastMCP) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ offers asynchronous execution for efficient performance.
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .auth_tools import get_current_session_token
|
|
||||||
from ..services.analysis_service import AnalysisService
|
from ..services.analysis_service import AnalysisService
|
||||||
|
from .auth_tools import get_current_session_token
|
||||||
|
|
||||||
|
|
||||||
def register_analysis_tools(mcp: FastMCP, analysis_service: AnalysisService):
|
def register_analysis_tools(mcp: FastMCP, analysis_service: AnalysisService):
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ Functions:
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .auth_tools import get_current_session_token
|
|
||||||
from ..services.game_service import GameService
|
from ..services.game_service import GameService
|
||||||
|
from .auth_tools import get_current_session_token
|
||||||
|
|
||||||
|
|
||||||
def register_game_tools(mcp: FastMCP, game_service: GameService):
|
def register_game_tools(mcp: FastMCP, game_service: GameService):
|
||||||
|
|
@ -99,7 +99,7 @@ def register_game_tools(mcp: FastMCP, game_service: GameService):
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_score": sum(g.total_score for g in games),
|
"total_score": sum(g.total_score for g in games),
|
||||||
"average_score": sum(g.total_score for g in games) / len(games) if games else 0,
|
"average_score": sum(g.total_score for g in games) / len(games) if games else 0,
|
||||||
"maps_played": list(set(g.map_name for g in games)),
|
"maps_played": list({g.map_name for g in games}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ evolution.
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .auth_tools import get_current_session_token
|
|
||||||
from ..monitoring import endpoint_monitor, schema_registry
|
from ..monitoring import endpoint_monitor, schema_registry
|
||||||
|
from .auth_tools import get_current_session_token
|
||||||
|
|
||||||
|
|
||||||
def register_monitoring_tools(mcp: FastMCP):
|
def register_monitoring_tools(mcp: FastMCP):
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ from the underlying service API. Tools return structured data for easy consumpti
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .auth_tools import get_current_session_token
|
|
||||||
from ..services.profile_service import ProfileService
|
from ..services.profile_service import ProfileService
|
||||||
|
from .auth_tools import get_current_session_token
|
||||||
|
|
||||||
|
|
||||||
def register_profile_tools(mcp: FastMCP, profile_service: ProfileService):
|
def register_profile_tools(mcp: FastMCP, profile_service: ProfileService):
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from geoguessr_mcp.api import GeoGuessrClient
|
||||||
from geoguessr_mcp.api.dynamic_response import DynamicResponse
|
from geoguessr_mcp.api.dynamic_response import DynamicResponse
|
||||||
from geoguessr_mcp.auth import SessionManager, UserSession
|
from geoguessr_mcp.auth import SessionManager, UserSession
|
||||||
from geoguessr_mcp.config import settings
|
from geoguessr_mcp.config import settings
|
||||||
from geoguessr_mcp.models import RoundGuess, Game
|
from geoguessr_mcp.models import Game, RoundGuess
|
||||||
from geoguessr_mcp.services import AnalysisService, GameService, ProfileService
|
from geoguessr_mcp.services import AnalysisService, GameService, ProfileService
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from geoguessr_mcp.api import DynamicResponse, GeoGuessrClient, EndpointInfo, Endpoints
|
from geoguessr_mcp.api import DynamicResponse, EndpointInfo, Endpoints, GeoGuessrClient
|
||||||
from geoguessr_mcp.config import settings
|
from geoguessr_mcp.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ class TestAuthenticationFlow:
|
||||||
assert "expired_user" not in session_manager._user_sessions
|
assert "expired_user" not in session_manager._user_sessions
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_cookie_fallback(self, session_manager):
|
async def test_default_cookie_fallback(self):
|
||||||
"""Test falling back to default cookie when no session exists."""
|
"""Test falling back to default cookie when no session exists."""
|
||||||
# Create manager with default cookie
|
# Create manager with default cookie
|
||||||
manager_with_default = SessionManager(default_cookie="default_test_cookie")
|
manager_with_default = SessionManager(default_cookie="default_test_cookie")
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ class TestMultiUserSessionManager:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_user_context_reuses_existing_manager(self, manager):
|
async def test_get_user_context_reuses_existing_manager(self, manager):
|
||||||
"""Test that getting context for existing API key reuses the same manager."""
|
"""Test that getting context for existing API key reuses the same manager."""
|
||||||
context1 = await manager.get_user_context("existing_key")
|
await manager.get_user_context("existing_key")
|
||||||
context2 = await manager.get_user_context("existing_key")
|
await manager.get_user_context("existing_key")
|
||||||
|
|
||||||
# Should use the same manager instance
|
# Should use the same manager instance
|
||||||
assert manager._user_managers["existing_key"] is manager._user_managers["existing_key"]
|
assert manager._user_managers["existing_key"] is manager._user_managers["existing_key"]
|
||||||
|
|
@ -36,9 +36,9 @@ class TestMultiUserSessionManager:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_api_keys_get_separate_managers(self, manager):
|
async def test_multiple_api_keys_get_separate_managers(self, manager):
|
||||||
"""Test that different API keys get separate session managers."""
|
"""Test that different API keys get separate session managers."""
|
||||||
context1 = await manager.get_user_context("key1")
|
await manager.get_user_context("key1")
|
||||||
context2 = await manager.get_user_context("key2")
|
await manager.get_user_context("key2")
|
||||||
context3 = await manager.get_user_context("key3")
|
await manager.get_user_context("key3")
|
||||||
|
|
||||||
assert len(manager._user_managers) == 3
|
assert len(manager._user_managers) == 3
|
||||||
assert manager._user_managers["key1"] is not manager._user_managers["key2"]
|
assert manager._user_managers["key1"] is not manager._user_managers["key2"]
|
||||||
|
|
@ -61,10 +61,11 @@ class TestMultiUserSessionManager:
|
||||||
assert session is None
|
assert session is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_login_user_creates_manager_if_not_exists(self, manager):
|
async def test_login_user_creates_manager_if_not_exists(self):
|
||||||
"""Test that login_user creates a manager if it doesn't exist."""
|
"""Test that login_user creates a manager if it doesn't exist."""
|
||||||
# This test requires mocking the HTTP client for GeoGuessr API
|
# This test requires mocking the HTTP client for GeoGuessr API
|
||||||
# We'll mark it as a placeholder for now
|
# We'll mark it as a placeholder for now
|
||||||
|
# TODO: Add test for this
|
||||||
pytest.skip("Requires mocking GeoGuessr API")
|
pytest.skip("Requires mocking GeoGuessr API")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"""Tests for UserContext class."""
|
"""Tests for UserContext class."""
|
||||||
|
|
||||||
import pytest
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from geoguessr_mcp.auth.session import UserSession
|
from geoguessr_mcp.auth.session import UserSession
|
||||||
from geoguessr_mcp.auth.user_context import UserContext
|
from geoguessr_mcp.auth.user_context import UserContext
|
||||||
from datetime import datetime, timedelta, UTC
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserContext:
|
class TestUserContext:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ feeds, recent games, season statistics, and daily challenges.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from geoguessr_mcp.models import Game, SeasonStats, DailyChallenge
|
from geoguessr_mcp.models import DailyChallenge, Game, SeasonStats
|
||||||
|
|
||||||
|
|
||||||
class TestGameService:
|
class TestGameService:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ operations.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from geoguessr_mcp.models import UserProfile, UserStats, Achievement
|
from geoguessr_mcp.models import Achievement, UserProfile, UserStats
|
||||||
|
|
||||||
|
|
||||||
class TestProfileService:
|
class TestProfileService:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue