commit ce5abcc2175557620a8d6e60fb12e86a2dd7e3f5 Author: Yûki VACHOT Date: Fri Nov 28 19:24:17 2025 +0100 Init Commit diff --git a/.devcontainer/Dockerfile.dev b/.devcontainer/Dockerfile.dev new file mode 100644 index 0000000..cdf2fb1 --- /dev/null +++ b/.devcontainer/Dockerfile.dev @@ -0,0 +1,53 @@ +# Development Dockerfile for GeoGuessr MCP Server +FROM python:3.13-slim + +# Prevent Python from writing pyc files and buffering stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + git \ + ssh \ + sudo \ + vim \ + nano \ + htop \ + procps \ + net-tools \ + iputils-ping \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for development +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ + && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# Install uv for fast package management +RUN pip install --no-cache-dir uv + +# Set up workspace +WORKDIR /workspace + +# Copy pyproject.toml and poetry.lock (or similar) first for layer caching +COPY pyproject.toml README.md ./ + +# Install project and dev dependencies using uv +RUN uv pip install --system --no-cache --upgrade pip && \ + uv pip install --system --no-cache -e ".[dev]" + +COPY . . + +# Switch to non-root user +USER $USERNAME + +# Set Python path +ENV PYTHONPATH=/workspace + +# Default command +CMD ["sleep", "infinity"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..59cdb95 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,56 @@ +{ + "name": "GeoGuessr MCP Server Dev", + "dockerComposeFile": "docker-compose.dev.yml", + "service": "dev", + "workspaceFolder": "/workspace", + + // Features to install + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // Configure tool-specific properties + "customizations": { + // VS Code settings (also works for some PyCharm features) + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "redhat.vscode-yaml" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.formatting.provider": "black", + "python.linting.enabled": true, + "python.linting.ruffEnabled": true + } + } + }, + + // Ports to forward + "forwardPorts": [8000], + + // Environment variables + "containerEnv": { + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1" + }, + + // Run commands after container is created + "postCreateCommand": "pip install -e '.[dev]' && pre-commit install || true", + + // Run commands when container starts + "postStartCommand": "echo 'Dev container ready! Run: python src/server.py'", + + // Mount the .env file if it exists + "mounts": [ + "source=${localWorkspaceFolder}/.env,target=/workspace/.env,type=bind,consistency=cached" + ], + + // User configuration + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.dev.yml b/.devcontainer/docker-compose.dev.yml new file mode 100644 index 0000000..e05c3e7 --- /dev/null +++ b/.devcontainer/docker-compose.dev.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + dev: + build: + context: .. + dockerfile: .devcontainer/Dockerfile.dev + volumes: + # Mount the workspace + - ..:/workspace:cached + # Persist VS Code extensions + - vscode-extensions:/home/vscode/.vscode-server/extensions + # Persist pip cache + - pip-cache:/home/vscode/.cache/pip + # Docker socket for Docker-in-Docker (optional) + # - /var/run/docker.sock:/var/run/docker.sock + + # Keep container running + command: sleep infinity + + # Environment + environment: + - PYTHONDONTWRITEBYTECODE=1 + - PYTHONUNBUFFERED=1 + # Load from .env file + - GEOGUESSR_NCFA_COOKIE=${GEOGUESSR_NCFA_COOKIE:-} + - MCP_TRANSPORT=streamable-http + - MCP_HOST=0.0.0.0 + - MCP_PORT=8000 + + # Ports + ports: + - "8000:8000" + - "5678:5678" # debugpy port + + # Network + networks: + - dev-network + +volumes: + vscode-extensions: + pip-cache: + +networks: + dev-network: + name: geoguessr-mcp-dev \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba101c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# GeoGuessr MCP Server Configuration +# Copy this file to .env and fill in your values + +# ============================================================================= +# REQUIRED: GeoGuessr Authentication +# ============================================================================= +# Your GeoGuessr _ncfa cookie for API authentication +# +# How to get your _ncfa cookie: +# 1. Log in to GeoGuessr in your browser +# 2. Open Developer Tools (F12 or Ctrl+Shift+I) +# 3. Go to the "Application" or "Storage" tab +# 4. Under "Cookies", find www.geoguessr.com +# 5. Look for the cookie named "_ncfa" +# 6. Copy its value and paste it below +# +# IMPORTANT: Keep this secret! Anyone with this cookie can access your account. +# The cookie typically expires after some time, so you may need to update it periodically. + +GEOGUESSR_NCFA_COOKIE=your_ncfa_cookie_value_here + +# ============================================================================= +# MCP Server Configuration +# ============================================================================= +# Transport protocol: "streamable-http" (recommended) or "sse" (legacy) +MCP_TRANSPORT=streamable-http + +# Port to expose the server on +MCP_PORT=8000 + +# ============================================================================= +# Optional: API Key Authentication (recommended for production) +# ============================================================================= +# If you want to require API key authentication for accessing the MCP server +# Uncomment and set a secure API key +# API_KEYS=your-secure-api-key-here,another-api-key-if-needed diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69b4d91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Environment files (contain secrets!) +.env +*.env.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Logs +*.log +logs/ + +# SSL certificates (if stored locally) +ssl/ +*.pem +*.crt +*.key \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/GeoguessrStatsMCP.iml b/.idea/GeoguessrStatsMCP.iml new file mode 100644 index 0000000..844fe24 --- /dev/null +++ b/.idea/GeoguessrStatsMCP.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..c74403d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..2e8e2fe --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,387 @@ +# Development Guide: GeoGuessr MCP Server + +This guide covers multiple ways to develop the GeoGuessr MCP Server using PyCharm, including remote development options. + +## Table of Contents + +1. [Option 1: PyCharm Remote Development (Gateway)](#option-1-pycharm-remote-development-gateway) +2. [Option 2: PyCharm with Docker Interpreter](#option-2-pycharm-with-docker-interpreter) +3. [Option 3: Dev Containers](#option-3-dev-containers) +4. [Option 4: SSH Remote Interpreter](#option-4-ssh-remote-interpreter) +5. [Running Tests](#running-tests) +6. [Debugging](#debugging) + +--- + +## Option 1: PyCharm Remote Development (Gateway) + +**Best for:** Full IDE experience on a remote server with PyCharm Professional. + +### Prerequisites +- PyCharm Professional 2023.2+ or JetBrains Gateway +- SSH access to your VPS + +### Setup Steps + +1. **Install JetBrains Gateway** (or use PyCharm Professional's remote dev feature) + - Download from: https://www.jetbrains.com/remote-development/gateway/ + +2. **Connect to your VPS** + ``` + Gateway → New Connection → SSH + Host: your-vps-ip + User: your-username + Authentication: Key pair or password + ``` + +3. **Clone the project on the server** + ```bash + ssh user@your-vps + git clone https://github.com/yourusername/geoguessr-mcp.git + cd geoguessr-mcp + ``` + +4. **Open in Gateway** + - Select IDE: PyCharm Professional + - Project directory: `/home/user/geoguessr-mcp` + - Click "Download and Start IDE" + +5. **Configure the environment** + - Gateway will install a remote IDE backend + - Set up your Python interpreter (see below) + - Copy `.env.example` to `.env` and add your cookie + +### Configure Python Interpreter in Gateway + +1. File → Settings → Project → Python Interpreter +2. Add Interpreter → Add Local Interpreter +3. Select "Virtualenv Environment" → New +4. Location: `/home/user/geoguessr-mcp/.venv` +5. Base interpreter: `/usr/bin/python3.12` +6. Click OK + +--- + +## Option 2: PyCharm with Docker Interpreter + +**Best for:** Consistent dev environment, running PyCharm locally with Docker. + +### Prerequisites +- PyCharm Professional +- Docker installed locally +- Docker Compose installed + +### Setup Steps + +1. **Open the project locally in PyCharm** + +2. **Build the dev container** + ```bash + cd geoguessr-mcp + docker compose -f .devcontainer/docker-compose.dev.yml build + ``` + +3. **Configure Docker Interpreter** + + a. File → Settings → Project → Python Interpreter + + b. Click the gear icon → Add + + c. Select "Docker Compose" + + d. Configuration file: `.devcontainer/docker-compose.dev.yml` + + e. Service: `dev` + + f. Python interpreter: `/usr/local/bin/python` + + g. Click OK + +4. **Configure Environment Variables** + + a. Run → Edit Configurations + + b. Select your run configuration + + c. Environment variables: Add `GEOGUESSR_NCFA_COOKIE=your_cookie` + + Or create a `.env` file: + ```bash + cp .env.example .env + # Edit .env with your cookie + ``` + +5. **Run/Debug** + - Use the run configurations provided in `.idea/runConfigurations/` + +### Docker Compose Commands + +```bash +# Start dev container +docker compose -f .devcontainer/docker-compose.dev.yml up -d + +# View logs +docker compose -f .devcontainer/docker-compose.dev.yml logs -f + +# Stop +docker compose -f .devcontainer/docker-compose.dev.yml down + +# Rebuild +docker compose -f .devcontainer/docker-compose.dev.yml build --no-cache +``` + +--- + +## Option 3: Dev Containers + +**Best for:** VS Code users or PyCharm 2024.1+ with Dev Containers support. + +### Using VS Code + +1. Install the "Dev Containers" extension + +2. Open the project folder + +3. Click "Reopen in Container" when prompted (or F1 → "Dev Containers: Reopen in Container") + +4. VS Code will build and connect to the dev container + +### Using PyCharm (2024.1+) + +PyCharm 2024.1+ has experimental Dev Containers support: + +1. File → Remote Development → Dev Containers + +2. Select the `.devcontainer/devcontainer.json` file + +3. PyCharm will build and connect to the container + +### Manual Container Development + +If your IDE doesn't support dev containers natively: + +```bash +# Start the dev container +docker compose -f .devcontainer/docker-compose.dev.yml up -d + +# Exec into the container +docker exec -it geoguessr-mcp-dev-1 bash + +# Inside container: run the server +cd /workspace +python server.py + +# Inside container: run tests +pytest -v +``` + +--- + +## Option 4: SSH Remote Interpreter + +**Best for:** PyCharm Professional users who want to run code directly on VPS. + +### Setup Steps + +1. **Prepare your VPS** + ```bash + ssh user@your-vps + + # Install Python 3.12 + sudo apt update + sudo apt install python3.12 python3.12-venv python3.12-dev + + # Clone the project + git clone https://github.com/yourusername/geoguessr-mcp.git + cd geoguessr-mcp + + # Create virtual environment + python3.12 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt -r requirements-dev.txt + ``` + +2. **Configure PyCharm SSH Interpreter** + + a. File → Settings → Project → Python Interpreter + + b. Click gear → Add → SSH Interpreter + + c. New server configuration: + - Host: your-vps-ip + - Username: your-username + - Authentication: Key pair + + d. Next → Interpreter path: `/home/user/geoguessr-mcp/.venv/bin/python` + + e. Sync folders: + - Local: Your local project path + - Remote: `/home/user/geoguessr-mcp` + +3. **Configure deployment** + + a. Tools → Deployment → Configuration + + b. Add SFTP server + + c. Enable automatic upload (Tools → Deployment → Automatic Upload) + +--- + +## Running Tests + +### From PyCharm +- Use the "Run Tests" configuration +- Or right-click on `tests/` → Run 'pytest in tests' + +### From Command Line + +```bash +# Run all tests +pytest -v + +# Run with coverage +pytest -v --cov=. --cov-report=html + +# Run specific test file +pytest tests/test_server.py -v + +# Run only unit tests (skip integration) +pytest -v -m "not integration" + +# Run integration tests (requires real cookie) +GEOGUESSR_NCFA_COOKIE=your_real_cookie pytest -v -m integration +``` + +--- + +## Debugging + +### PyCharm Debugger + +1. Set breakpoints by clicking in the gutter + +2. Run → Debug 'Debug MCP Server' + +3. Use the debugger panel to: + - Step through code (F8) + - Step into functions (F7) + - Evaluate expressions + - View variables + +### Remote Debugging (debugpy) + +For debugging in Docker or remote environments: + +1. **Modify server.py** to enable debugpy: + ```python + import debugpy + debugpy.listen(("0.0.0.0", 5678)) + print("Waiting for debugger...") + debugpy.wait_for_client() + ``` + +2. **Configure PyCharm Remote Debug** + + a. Run → Edit Configurations → Add → Python Debug Server + + b. IDE host name: `localhost` + + c. Port: `5678` + + d. Path mappings: + - Local: `/path/to/local/project` + - Remote: `/workspace` + +3. **Start debugging** + - Start the server (it will wait for debugger) + - Run the "Python Debug Server" configuration in PyCharm + - Server will continue execution + +### Logging + +The server uses Python's logging module. Increase verbosity: + +```python +# In server.py, change: +logging.basicConfig(level=logging.DEBUG) +``` + +Or via environment: +```bash +export LOG_LEVEL=DEBUG +python server.py +``` + +--- + +## Code Quality + +### Format Code + +```bash +# Black +black . + +# Ruff (lint + fix) +ruff check --fix . +``` + +### Type Checking + +```bash +mypy server.py +``` + +### Pre-commit Hooks + +```bash +# Install hooks +pre-commit install + +# Run manually +pre-commit run --all-files +``` + +--- + +## Project Structure + +``` +geoguessr-mcp/ +├── .devcontainer/ # Dev container configuration +│ ├── devcontainer.json +│ ├── docker-compose.dev.yml +│ └── Dockerfile.dev +├── .idea/ # PyCharm settings +│ └── runConfigurations/ +├── tests/ # Test files +│ ├── __init__.py +│ └── test_server.py +├── server.py # Main MCP server +├── requirements.txt # Production dependencies +├── requirements-dev.txt # Development dependencies +├── pyproject.toml # Project configuration +├── Dockerfile # Production Dockerfile +├── docker-compose.yml # Production compose +└── README.md +``` + +--- + +## Tips + +1. **Hot Reload**: Use `watchfiles` for auto-restart during development: + ```bash + pip install watchfiles + watchfiles "python server.py" . + ``` + +2. **Test MCP Connection**: Use the test script: + ```bash + python test_server.py http://localhost:8000/mcp + ``` + +3. **Environment Variables**: Always use `.env` file locally, never commit secrets + +4. **Cookie Expiration**: GeoGuessr cookies expire - if tests start failing, get a fresh cookie \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56949c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# GeoGuessr MCP Server Dockerfile +FROM python:3.13-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for faster package installation +RUN pip install --no-cache-dir uv + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN uv pip install --system --no-cache -r requirements.txt + +# Copy application code +COPY server.py . + +# Expose the port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the server +CMD ["python", "server.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..163375a --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# GeoGuessr MCP Server + +A Model Context Protocol (MCP) server for analyzing your GeoGuessr account data. This server allows Claude AI to access and analyze your GeoGuessr statistics, game history, achievements, and more. + +## Authentication Options + +The server supports **three authentication methods**: + +1. **Login with credentials** (Recommended) - Use the `login` tool with your GeoGuessr email and password +2. **Manual cookie** - Use the `set_ncfa_cookie` tool with a cookie extracted from your browser +3. **Environment variable** - Set `GEOGUESSR_NCFA_COOKIE` for server-wide default authentication + +### Using Login (Easiest) + +Simply ask Claude to login: +``` +"Login to GeoGuessr with email: myemail@example.com and password: mypassword" +``` + +The server will authenticate with GeoGuessr and create a session. Your credentials are only used once to obtain an authentication token - they are **not stored** on the server. + +### Security Notes + +- Credentials are sent directly to GeoGuessr's official API over HTTPS +- Session tokens are stored in memory only (lost on server restart) +- For persistent authentication, use the environment variable method +- Always use HTTPS in production to protect credentials in transit + +## Features + +### Authentication Tools +- **login** - Authenticate with email/password +- **logout** - End the current session +- **set_ncfa_cookie** - Set authentication cookie manually +- **set_session_token** - Restore a previous session +- **get_auth_status** - Check current authentication status + +### Profile & Stats +- **get_my_profile** - Get your user profile information +- **get_my_stats** - Get detailed statistics displayed on your profile +- **get_extended_stats** - Get additional stats not shown on profile +- **get_my_achievements** - Get all your achievements +- **get_my_trophies** - Get your trophy collection +- **get_trophy_case** - Get your displayed trophy case + +### Games & Activity +- **get_activity_feed** - Get recent activity (games, achievements, etc.) +- **get_game_details** - Get detailed info about a specific game +- **get_unfinished_games** - Get games you haven't completed +- **get_streak_game** - Get country streak game details +- **analyze_recent_games** - Analyze your recent games with statistics + +### Competitive Modes +- **get_battle_royale_game** - Get Battle Royale game stats +- **get_duel_game** - Get duel game information +- **get_game_lobby** - Get lobby info with player stats +- **get_current_season_stats** - Get current season statistics +- **get_season_game_info** - Get season info for specific game modes +- **get_tournaments** - Get tournament information + +### Challenges +- **get_daily_challenge** - Get today's or previous daily challenges +- **get_challenge_details** - Get details about a specific challenge + +### Social +- **get_friends** - Get your friends list +- **get_friends_summary** - Get friends with requests and recommendations +- **get_notifications** - Get your notifications +- **search_user** - Search for other players + +### Maps +- **get_my_maps** - Get maps you've created +- **get_liked_maps** - Get maps you've liked +- **get_map_info** - Get info about any map +- **get_popular_maps** - Get popular/featured/official maps +- **get_personalized_maps** - Get map recommendations +- **get_map_scores** - Get high scores for a map + +### Explorer & Progress +- **get_explorer_progress** - Get your explorer mode progress +- **get_objectives** - Get current objectives +- **get_unclaimed_objectives** - Get rewards to claim +- **get_unclaimed_badges** - Get badges to claim + +### Analysis +- **get_performance_summary** - Get comprehensive performance overview + +## Prerequisites + +- Docker and Docker Compose installed on your VPS +- A GeoGuessr account (Pro subscription recommended for full API access) +- Your GeoGuessr `_ncfa` authentication cookie + +## Quick Start + +### 1. Clone or copy the files to your VPS + +```bash +mkdir -p ~/geoguessr-mcp +cd ~/geoguessr-mcp +# Copy all the files here +``` + +### 2. Build and run (no configuration required!) + +```bash +docker compose up -d --build +``` + +That's it! The server now supports login via credentials, so you don't need to configure anything upfront. + +### 3. (Optional) Configure default authentication + +If you want server-wide default authentication without logging in each time: + +#### Option A: Get your GeoGuessr authentication cookie + +1. Log in to [GeoGuessr](https://www.geoguessr.com) in your browser +2. Open Developer Tools (F12 or Ctrl+Shift+I) +3. Go to the **Application** tab (Chrome) or **Storage** tab (Firefox) +4. Under **Cookies**, find `www.geoguessr.com` +5. Look for the cookie named `_ncfa` +6. Copy its value + +```bash +cp .env.example .env +nano .env # Add your cookie +``` + +```env +GEOGUESSR_NCFA_COOKIE=your_actual_cookie_value_here +``` + +#### Option B: Just use login when connected + +When connected to Claude, simply say: +> "Login to GeoGuessr with my email and password" + +Claude will prompt you for credentials and authenticate. + +### 4. Restart if you added environment variables + +```bash +docker compose up -d --build +``` + +### 5. Verify it's running + +```bash +docker compose logs -f +``` + +You should see: +``` +Starting GeoGuessr MCP Server on 0.0.0.0:8000 with streamable-http transport +``` + +## Connecting to Claude + +### Claude Desktop (macOS/Windows) + +Add to your Claude Desktop configuration file: + +**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows:** `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "geoguessr": { + "type": "streamable-http", + "url": "http://YOUR_VPS_IP:8000/mcp" + } + } +} +``` + +### VS Code with Copilot + +1. Open VS Code +2. Run the command: `MCP: Add Server` +3. Choose "HTTP (Streamable HTTP)" as the transport +4. Enter URL: `http://YOUR_VPS_IP:8000/mcp` + +### Claude.ai (if MCP support is enabled) + +Add the server through Claude.ai's integrations settings with: +- URL: `http://YOUR_VPS_IP:8000/mcp` +- Transport: Streamable HTTP + +## Using with Claude + +Once connected, you can ask Claude questions like: + +**First, authenticate (if not using environment variable):** +- "Login to GeoGuessr with email: myemail@example.com password: mypassword" +- "Check my GeoGuessr authentication status" + +**Then analyze your data:** +- "Show me my GeoGuessr profile and stats" +- "Analyze my last 10 games and tell me how I'm doing" +- "What achievements have I unlocked?" +- "How am I doing in the current competitive season?" +- "Show me my activity feed from the last week" +- "What maps have I liked?" +- "Search for a player named [username]" +- "Get the details of my last Battle Royale game" + +**When done (optional):** +- "Logout from GeoGuessr" + +## Production Deployment + +### Adding SSL with Nginx (Recommended) + +For production, you should add SSL. Create an `nginx.conf`: + +```nginx +events { + worker_connections 1024; +} + +http { + upstream mcp_server { + server geoguessr-mcp:8000; + } + + server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + location / { + proxy_pass http://mcp_server; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + } +} +``` + +### Adding API Key Authentication + +For additional security, you can add API key authentication. Modify the server.py to check for an `x-api-key` header. + +### Firewall Configuration + +```bash +# Allow only specific IPs or use a firewall +sudo ufw allow from YOUR_IP to any port 8000 +``` + +## Troubleshooting + +### "GEOGUESSR_NCFA_COOKIE environment variable not set" + +Make sure your `.env` file exists and contains the cookie: +```bash +cat .env | grep GEOGUESSR +``` + +### Cookie expired + +GeoGuessr cookies expire periodically. If API calls start failing, get a fresh cookie from your browser. + +### Connection refused + +1. Check if the container is running: `docker compose ps` +2. Check logs: `docker compose logs geoguessr-mcp` +3. Verify the port is exposed: `docker port geoguessr-mcp-server` + +### API rate limiting + +GeoGuessr may rate-limit excessive API calls. Space out requests if you're hitting limits. + +## Cookie Security + +⚠️ **Important Security Notes:** + +1. The `_ncfa` cookie provides full access to your GeoGuessr account +2. Never share your `.env` file or commit it to version control +3. Consider running the server on a private network +4. Use SSL in production +5. Regularly rotate your cookie + +## Development + +To run locally without Docker: + +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate # or `venv\Scripts\activate` on Windows + +# Install dependencies +pip install -r requirements.txt + +# Set environment variable +export GEOGUESSR_NCFA_COOKIE="your_cookie_here" + +# Run the server +python server.py +``` + +## API Reference + +The server uses the unofficial GeoGuessr API. Key endpoints: + +- `https://www.geoguessr.com/api/v3/` - Main API (v3) +- `https://www.geoguessr.com/api/v4/` - Newer API (v4) +- `https://game-server.geoguessr.com/api/` - Game server API + +Note: This is an unofficial API and may change without notice. + +## License + +MIT License - Feel free to modify and distribute. + +## Disclaimer + +This project is not affiliated with, endorsed by, or connected to GeoGuessr AB. Use at your own risk and in accordance with GeoGuessr's Terms of Service. \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b97c42e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + geoguessr-mcp: + build: + context: . + dockerfile: Dockerfile + container_name: geoguessr-mcp-server + restart: unless-stopped + ports: + - "${MCP_PORT:-8000}:8000" + environment: + # Required: Your GeoGuessr _ncfa cookie for authentication + - GEOGUESSR_NCFA_COOKIE=${GEOGUESSR_NCFA_COOKIE} + # MCP Server configuration + - MCP_TRANSPORT=${MCP_TRANSPORT:-streamable-http} + - MCP_HOST=0.0.0.0 + - MCP_PORT=8000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Optional: Nginx reverse proxy with SSL (recommended for production) + # Uncomment and configure if you want SSL termination + # nginx: + # image: nginx:alpine + # container_name: geoguessr-mcp-nginx + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./nginx.conf:/etc/nginx/nginx.conf:ro + # - ./ssl:/etc/nginx/ssl:ro + # depends_on: + # - geoguessr-mcp + +networks: + default: + name: geoguessr-mcp-network \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34ec30c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,162 @@ +[project] +name = "geoguessr-mcp" +version = "0.1.0" +description = "MCP server for analyzing GeoGuessr account data" +readme = "README.md" +requires-python = ">=3.13" +license = {text = "MIT"} +authors = [ + {name = "Yûki VACHOT", email = "yuki.vachot@datasingularity.fr"} +] +keywords = ["mcp", "geoguessr", "claude", "ai", "model-context-protocol"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "mcp[cli]>=1.4.0", + "httpx>=0.27.0", + "uvicorn>=0.30.0", + "starlette>=0.38.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "respx>=0.20.0", + "black>=24.0.0", + "ruff>=0.3.0", + "mypy>=1.8.0", + "pre-commit>=3.6.0", + "debugpy>=1.8.0", + "ipython>=8.0.0", + "rich>=13.0.0" +] + +[project.scripts] +geoguessr-mcp = "server:main" + +[project.urls] +Homepage = "https://github.com/yourusername/geoguessr-mcp" +Repository = "https://github.com/yourusername/geoguessr-mcp" +Issues = "https://github.com/yourusername/geoguessr-mcp/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +# Black configuration +[tool.black] +line-length = 100 +target-version = ['py310', 'py311', 'py312', 'py313'] +include = '\.pyi?$' +exclude = ''' +/( + \.eggs + | \.git + | \.hatch + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +# Ruff configuration +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by black) + "B008", # do not perform function calls in argument defaults + "C901", # too complex + "ARG001", # unused function argument +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG001", "S101"] + +# Pytest configuration +[tool.pytest.ini_options] +minversion = "8.0" +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +# Coverage configuration +[tool.coverage.run] +source = ["."] +omit = [ + "tests/*", + ".venv/*", + "*.pyc", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +# MyPy configuration +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "silent" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false \ No newline at end of file diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..a090dc4 --- /dev/null +++ b/src/server.py @@ -0,0 +1,1089 @@ +""" +GeoGuessr MCP Server +A Model Context Protocol server for analyzing GeoGuessr account data. + +Supports two authentication modes: +1. Environment variable: Set GEOGUESSR_NCFA_COOKIE for single-user/server-wide auth +2. Per-user login: Use the login tool with email/password to get a session +""" + +import os +import sys +import json +import logging +import hashlib +import secrets +from typing import Any, Optional +from datetime import datetime, timedelta +from dataclasses import dataclass, field +import asyncio +import httpx +from mcp.server.fastmcp import FastMCP + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# GeoGuessr API configuration +GEOGUESSR_BASE_URL = "https://www.geoguessr.com/api" +GAME_SERVER_URL = "https://game-server.geoguessr.com/api" + + +# ============================================================================ +# SERVER ENTRY POINT +# ============================================================================ + +if __name__ == "__main__": + # Check for environment variable (optional now) + if not os.environ.get("GEOGUESSR_NCFA_COOKIE"): + logger.info("No GEOGUESSR_NCFA_COOKIE set. Users can authenticate via the 'login' tool.") + else: + logger.info("GEOGUESSR_NCFA_COOKIE found. Default authentication available.") + + # Get transport from environment or default to streamable-http for remote access + transport = os.environ.get("MCP_TRANSPORT", "streamable-http") + host = os.environ.get("MCP_HOST", "0.0.0.0") + port = int(os.environ.get("MCP_PORT", "8000")) + + logger.info(f"Starting GeoGuessr MCP Server on {host}:{port} with {transport} transport") + logger.info("Authentication methods available:") + logger.info(" 1. 'login' tool - authenticate with email/password") + logger.info(" 2. 'set_ncfa_cookie' tool - set cookie manually") + logger.info(" 3. GEOGUESSR_NCFA_COOKIE env var - server-wide default") + + # Initialize FastMCP server + mcp = FastMCP( + "GeoGuessr Analyzer", + instructions="MCP server for analyzing GeoGuessr game statistics and account data", + host=host, + port=port + ) + + mcp.run(transport=transport) + + +# ============================================================================ +# SESSION MANAGEMENT +# ============================================================================ + +@dataclass +class UserSession: + """Represents an authenticated GeoGuessr session.""" + ncfa_cookie: str + user_id: str + username: str + email: str + created_at: datetime = field(default_factory=datetime.utcnow) + expires_at: Optional[datetime] = None + + def is_valid(self) -> bool: + """Check if the session is still valid.""" + if self.expires_at and datetime.utcnow() > self.expires_at: + return False + return bool(self.ncfa_cookie) + + +class SessionManager: + """Manages user sessions for the MCP server.""" + + def __init__(self): + self._sessions: dict[str, UserSession] = {} # session_token -> UserSession + self._user_sessions: dict[str, str] = {} # user_id -> session_token + self._default_cookie: Optional[str] = os.environ.get("GEOGUESSR_NCFA_COOKIE") + self._lock = asyncio.Lock() + + def _generate_session_token(self) -> str: + """Generate a secure session token.""" + return secrets.token_urlsafe(32) + + async def login(self, email: str, password: str) -> tuple[str, UserSession]: + """ + Authenticate with GeoGuessr and create a session. + Returns (session_token, UserSession) on success. + Raises ValueError on authentication failure. + """ + async with httpx.AsyncClient(timeout=30.0) as client: + # Attempt to sign in + response = await client.post( + f"{GEOGUESSR_BASE_URL}/v3/accounts/signin", + json={"email": email, "password": password}, + headers={"Content-Type": "application/json"} + ) + + if response.status_code == 401: + raise ValueError("Invalid email or password") + elif response.status_code == 403: + raise ValueError("Account access denied. Please check your credentials or try again later.") + elif response.status_code == 429: + raise ValueError("Too many login attempts. Please try again later.") + elif response.status_code != 200: + raise ValueError(f"Login failed with status {response.status_code}: {response.text}") + + # Extract the _ncfa cookie from response + ncfa_cookie = None + for cookie in response.cookies.jar: + if cookie.name == "_ncfa": + ncfa_cookie = cookie.value + break + + if not ncfa_cookie: + # Sometimes the cookie is in Set-Cookie header + set_cookie = response.headers.get("set-cookie", "") + if "_ncfa=" in set_cookie: + # Parse _ncfa value from Set-Cookie header + for part in set_cookie.split(";"): + if part.strip().startswith("_ncfa="): + ncfa_cookie = part.strip()[6:] + break + + if not ncfa_cookie: + raise ValueError("Authentication succeeded but no session cookie received") + + # Get a user profile with the new cookie + client.cookies.set("_ncfa", ncfa_cookie, domain="www.geoguessr.com") + profile_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + + if profile_response.status_code != 200: + raise ValueError("Failed to retrieve user profile after login") + + profile = profile_response.json() + + # Create session + session = UserSession( + ncfa_cookie=ncfa_cookie, + user_id=profile.get("id", ""), + username=profile.get("nick", ""), + email=email, + expires_at=datetime.utcnow() + timedelta(days=30) # Sessions typically last ~30 days + ) + + # Store session + async with self._lock: + session_token = self._generate_session_token() + + # Remove old session for this user if exists + if session.user_id in self._user_sessions: + old_token = self._user_sessions[session.user_id] + self._sessions.pop(old_token, None) + + self._sessions[session_token] = session + self._user_sessions[session.user_id] = session_token + + logger.info(f"User {session.username} logged in successfully") + return session_token, session + + async def logout(self, session_token: str) -> bool: + """Logout and invalidate a session.""" + async with self._lock: + if session_token in self._sessions: + session = self._sessions.pop(session_token) + self._user_sessions.pop(session.user_id, None) + logger.info(f"User {session.username} logged out") + return True + return False + + async def get_session(self, session_token: Optional[str] = None) -> Optional[UserSession]: + """Get a session by token, or return default if available.""" + if session_token: + async with self._lock: + session = self._sessions.get(session_token) + if session and session.is_valid(): + return session + elif session: + # Session expired, clean up + self._sessions.pop(session_token, None) + self._user_sessions.pop(session.user_id, None) + + # Fall back to default cookie from environment + if self._default_cookie: + return UserSession( + ncfa_cookie=self._default_cookie, + user_id="default", + username="default", + email="default@env" + ) + + return None + + def get_ncfa_cookie(self, session_token: Optional[str] = None) -> str: + """Synchronous method to get cookie for backward compatibility.""" + if session_token and session_token in self._sessions: + session = self._sessions[session_token] + if session.is_valid(): + return session.ncfa_cookie + + if self._default_cookie: + return self._default_cookie + + raise ValueError( + "No valid session. Please either:\n" + "1. Set GEOGUESSR_NCFA_COOKIE environment variable, or\n" + "2. Use the 'login' tool to authenticate with your GeoGuessr credentials" + ) + + async def list_sessions(self) -> list[dict]: + """List all active sessions (for admin purposes).""" + async with self._lock: + return [ + { + "username": s.username, + "user_id": s.user_id, + "created_at": s.created_at.isoformat(), + "expires_at": s.expires_at.isoformat() if s.expires_at else None, + "is_valid": s.is_valid() + } + for s in self._sessions.values() + ] + + +# Global session manager +session_manager = SessionManager() + +# Current session token (for simple single-user scenarios via tools) +# In a real multi-user setup, this would be passed via context/headers +_current_session_token: Optional[str] = None + + +async def get_async_session(session_token: Optional[str] = None) -> httpx.AsyncClient: + """Create an async HTTP client with authentication.""" + token = session_token or _current_session_token + session = await session_manager.get_session(token) + + if not session: + raise ValueError( + "Not authenticated. Please either:\n" + "1. Set GEOGUESSR_NCFA_COOKIE environment variable, or\n" + "2. Use the 'login' tool to authenticate with your GeoGuessr credentials" + ) + + client = httpx.AsyncClient(timeout=30.0) + client.cookies.set("_ncfa", session.ncfa_cookie, domain="www.geoguessr.com") + return client + + +# ============================================================================ +# AUTHENTICATION TOOLS +# ============================================================================ + +@mcp.tool() +async def login(email: str, password: str) -> dict: + """ + Authenticate with GeoGuessr using your email and password. + This creates a session that will be used for all subsequent API calls. + + Args: + email: Your GeoGuessr account email + password: Your GeoGuessr account password + + Returns: + Session information including username and session token + + Note: Your credentials are only used to obtain an authentication token + from GeoGuessr. They are not stored on the server. + """ + global _current_session_token + + try: + session_token, session = await session_manager.login(email, password) + _current_session_token = session_token + + return { + "success": True, + "message": f"Successfully logged in as {session.username}", + "username": session.username, + "user_id": session.user_id, + "session_token": session_token, + "expires_at": session.expires_at.isoformat() if session.expires_at else None + } + except ValueError as e: + return { + "success": False, + "error": str(e) + } + except Exception as e: + logger.error(f"Login error: {e}") + return { + "success": False, + "error": f"An unexpected error occurred: {str(e)}" + } + + +@mcp.tool() +async def logout() -> dict: + """ + Logout from the current GeoGuessr session. + This invalidates the current session token. + """ + global _current_session_token + + if _current_session_token: + success = await session_manager.logout(_current_session_token) + _current_session_token = None + return { + "success": success, + "message": "Successfully logged out" if success else "No active session to logout" + } + + return { + "success": False, + "message": "No active session" + } + + +@mcp.tool() +async def set_session_token(token: str) -> dict: + """ + Set an existing session token for authentication. + Use this if you have a previously obtained session token. + + Args: + token: A valid session token from a previous login + """ + global _current_session_token + + session = await session_manager.get_session(token) + if session and session.is_valid(): + _current_session_token = token + return { + "success": True, + "message": f"Session set for user {session.username}", + "username": session.username + } + + return { + "success": False, + "error": "Invalid or expired session token" + } + + +@mcp.tool() +async def set_ncfa_cookie(cookie: str) -> dict: + """ + Directly set the _ncfa cookie for authentication. + Use this if you've manually extracted the cookie from your browser. + + Args: + cookie: The _ncfa cookie value from your browser + + Note: This sets the cookie as the default for all requests. + """ + global _current_session_token + + # Validate the cookie by making a test request + async with httpx.AsyncClient(timeout=30.0) as client: + client.cookies.set("_ncfa", cookie, domain="www.geoguessr.com") + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + + if response.status_code != 200: + return { + "success": False, + "error": "Invalid cookie - authentication failed" + } + + profile = response.json() + + # Create a session from the cookie + session = UserSession( + ncfa_cookie=cookie, + user_id=profile.get("id", ""), + username=profile.get("nick", ""), + email="manual@cookie", + expires_at=datetime.utcnow() + timedelta(days=30) + ) + + # Store as a session + session_token = secrets.token_urlsafe(32) + async with session_manager._lock: + session_manager._sessions[session_token] = session + session_manager._user_sessions[session.user_id] = session_token + + _current_session_token = session_token + + return { + "success": True, + "message": f"Cookie set successfully. Authenticated as {session.username}", + "username": session.username, + "user_id": session.user_id, + "session_token": session_token + } + + +@mcp.tool() +async def get_auth_status() -> dict: + """ + Check the current authentication status. + Returns information about the current session or authentication method. + """ + global _current_session_token + + # Check for active session + if _current_session_token: + session = await session_manager.get_session(_current_session_token) + if session and session.is_valid(): + return { + "authenticated": True, + "method": "session", + "username": session.username, + "user_id": session.user_id, + "expires_at": session.expires_at.isoformat() if session.expires_at else None + } + + # Check for environment variable + env_cookie = os.environ.get("GEOGUESSR_NCFA_COOKIE") + if env_cookie: + # Validate the environment cookie + try: + async with httpx.AsyncClient(timeout=30.0) as client: + client.cookies.set("_ncfa", env_cookie, domain="www.geoguessr.com") + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + + if response.status_code == 200: + profile = response.json() + return { + "authenticated": True, + "method": "environment_variable", + "username": profile.get("nick", "Unknown"), + "user_id": profile.get("id", "Unknown") + } + except Exception: + pass + + return { + "authenticated": False, + "message": "Not authenticated. Use 'login' with your GeoGuessr credentials or 'set_ncfa_cookie' with a valid cookie." + } + + +# ============================================================================ +# PROFILE TOOLS +# ============================================================================ + +@mcp.tool() +async def get_my_profile() -> dict: + """ + Get the profile information of the currently logged-in GeoGuessr user. + Returns user details including username, country, level, and basic stats. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_my_stats() -> dict: + """ + Get detailed statistics for the currently logged-in user. + Returns stats displayed on the profile page including games played, + average scores, and performance metrics. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/stats") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_extended_stats() -> dict: + """ + Get extended statistics for the currently logged-in user. + Returns additional stats not shown on the main profile page. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/stats/me") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_my_achievements() -> dict: + """ + Get all achievements for the currently logged-in user. + Returns completed and in-progress achievements. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/achievements") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_my_trophies(user_id: Optional[str] = None) -> dict: + """ + Get trophies for a user. If no user_id is provided, gets trophies for the logged-in user. + + Args: + user_id: Optional user ID. If not provided, uses the logged-in user. + """ + async with await get_async_session() as client: + if user_id: + url = f"{GEOGUESSR_BASE_URL}/v4/trophies/{user_id}" + else: + profile_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + profile_response.raise_for_status() + profile = profile_response.json() + user_id = profile.get("id") + url = f"{GEOGUESSR_BASE_URL}/v4/trophies/{user_id}" + + response = await client.get(url) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_trophy_case(user_id: Optional[str] = None) -> dict: + """ + Get the trophy case (selected/displayed trophies) for a user. + + Args: + user_id: Optional user ID. If not provided, uses the logged-in user. + """ + async with await get_async_session() as client: + if user_id: + url = f"{GEOGUESSR_BASE_URL}/v4/trophies/{user_id}/case" + else: + profile_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + profile_response.raise_for_status() + profile = profile_response.json() + user_id = profile.get("id") + url = f"{GEOGUESSR_BASE_URL}/v4/trophies/{user_id}/case" + + response = await client.get(url) + response.raise_for_status() + return response.json() + + +# ============================================================================ +# ACTIVITY & GAMES TOOLS +# ============================================================================ + +@mcp.tool() +async def get_activity_feed(count: int = 20, page: int = 0) -> dict: + """ + Get the activity feed (games played, achievements, etc.) for the logged-in user. + This includes game tokens that can be used to fetch detailed game information. + + Args: + count: Number of activities to return (default: 20) + page: Page number for pagination (default: 0) + """ + async with await get_async_session() as client: + response = await client.get( + f"{GEOGUESSR_BASE_URL}/v4/feed/private", + params={"count": count, "page": page} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_game_details(game_token: str) -> dict: + """ + Get detailed information about a specific game including rounds, scores, and locations. + + Args: + game_token: The game token/ID (found in game URLs or activity feed) + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/games/{game_token}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_unfinished_games() -> dict: + """ + Get list of unfinished games for the logged-in user. + Returns games that were started but not completed. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/social/events/unfinishedgames") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_streak_game(game_token: str) -> dict: + """ + Get details of a country streak game. + + Args: + game_token: The streak game token + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/games/streak/{game_token}") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# BATTLE ROYALE & COMPETITIVE TOOLS +# ============================================================================ + +@mcp.tool() +async def get_battle_royale_game(game_id: str) -> dict: + """ + Get statistics for a Battle Royale game. + + Args: + game_id: The Battle Royale game ID + """ + async with await get_async_session() as client: + response = await client.get(f"{GAME_SERVER_URL}/battle-royale/{game_id}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_duel_game(duel_id: str) -> dict: + """ + Get information about a duel game. + + Args: + duel_id: The duel game ID + """ + async with await get_async_session() as client: + response = await client.get(f"{GAME_SERVER_URL}/duels/{duel_id}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_game_lobby(game_id: str) -> dict: + """ + Get lobby information for a game including players and their stats. + + Args: + game_id: The game ID + """ + async with await get_async_session() as client: + response = await client.get(f"{GAME_SERVER_URL}/lobby/{game_id}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_current_season_stats() -> dict: + """ + Get statistics for the current competitive season. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/seasons/active/stats") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_season_game_info(game_mode: str = "BattleRoyaleCountries") -> dict: + """ + Get season information for a specific game mode. + + Args: + game_mode: One of "BattleRoyaleCountries", "BattleRoyaleDistance", or "BattleRoyaleDuels" + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/seasons/game/{game_mode}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_tournaments() -> dict: + """ + Get information about current and past tournaments. + """ + async with await get_async_session() as client: + response = await client.get(f"{GAME_SERVER_URL}/tournaments") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# CHALLENGES TOOLS +# ============================================================================ + +@mcp.tool() +async def get_daily_challenge(which: str = "today") -> dict: + """ + Get information about the daily challenge. + + Args: + which: Either "today" for today's challenge or "previous" for previous challenges + """ + async with await get_async_session() as client: + endpoint = "today" if which == "today" else "previous" + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/challenges/daily-challenges/{endpoint}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_challenge_details(challenge_token: str) -> dict: + """ + Get detailed information about a specific challenge. + + Args: + challenge_token: The challenge token/ID + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/challenges/{challenge_token}") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# SOCIAL TOOLS +# ============================================================================ + +@mcp.tool() +async def get_friends(count: int = 50, page: int = 0) -> dict: + """ + Get the friends list for the logged-in user. + + Args: + count: Number of friends to return (default: 50) + page: Page number for pagination (default: 0) + """ + async with await get_async_session() as client: + response = await client.get( + f"{GEOGUESSR_BASE_URL}/v3/social/friends", + params={"count": count, "page": page} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_friends_summary() -> dict: + """ + Get friends list along with friend requests and recommendations. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/social/friends/summary") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_notifications(count: int = 20, page: int = 0) -> dict: + """ + Get notifications for the logged-in user. + + Args: + count: Number of notifications to return (default: 20) + page: Page number for pagination (default: 0) + """ + async with await get_async_session() as client: + response = await client.get( + f"{GEOGUESSR_BASE_URL}/v4/notifications", + params={"count": count, "page": page} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def search_user(query: str) -> dict: + """ + Search for a GeoGuessr user by name. + + Args: + query: The search query (username to search for) + """ + async with await get_async_session() as client: + response = await client.get( + f"{GEOGUESSR_BASE_URL}/v3/search/user", + params={"q": query} + ) + response.raise_for_status() + return response.json() + + +# ============================================================================ +# MAPS TOOLS +# ============================================================================ + +@mcp.tool() +async def get_my_maps() -> dict: + """ + Get maps created by the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/maps") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_liked_maps(count: int = 50, page: int = 0) -> dict: + """ + Get maps liked by the logged-in user. + + Args: + count: Number of maps to return (default: 50) + page: Page number for pagination (default: 0) + """ + async with await get_async_session() as client: + response = await client.get( + f"{GEOGUESSR_BASE_URL}/v3/likes", + params={"count": count, "page": page} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_map_info(map_id: str) -> dict: + """ + Get information about a specific map. + + Args: + map_id: The map ID or slug (e.g., "world", "famous-places", or a UUID) + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/maps/{map_id}") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_popular_maps(category: str = "all") -> dict: + """ + Get popular maps. + + Args: + category: One of "all", "official", or "featured" + """ + async with await get_async_session() as client: + if category == "featured": + url = f"{GEOGUESSR_BASE_URL}/v3/social/maps/browse/featured" + elif category == "official": + url = f"{GEOGUESSR_BASE_URL}/v3/social/maps/browse/popular/official" + else: + url = f"{GEOGUESSR_BASE_URL}/v3/social/maps/browse/popular/all" + + response = await client.get(url) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_personalized_maps() -> dict: + """ + Get personalized map recommendations for the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/social/maps/browse/personalized") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_map_scores(map_id: str) -> dict: + """ + Get high scores for a specific map. + + Args: + map_id: The map ID + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/scores/maps/{map_id}") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# EXPLORER MODE TOOLS +# ============================================================================ + +@mcp.tool() +async def get_explorer_progress() -> dict: + """ + Get explorer mode progress for the logged-in user. + Shows which countries/regions have been explored. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/explorer") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# OBJECTIVES & BADGES TOOLS +# ============================================================================ + +@mcp.tool() +async def get_objectives() -> dict: + """ + Get current objectives for the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/objectives") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_unclaimed_objectives() -> dict: + """ + Get unclaimed objective rewards for the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/objectives/unclaimed") + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def get_unclaimed_badges() -> dict: + """ + Get unclaimed badges for the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/social/badges/unclaimed") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# SUBSCRIPTION & ACCOUNT TOOLS +# ============================================================================ + +@mcp.tool() +async def get_subscription_info() -> dict: + """ + Get subscription information for the logged-in user. + """ + async with await get_async_session() as client: + response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/subscriptions") + response.raise_for_status() + return response.json() + + +# ============================================================================ +# ANALYSIS TOOLS +# ============================================================================ + +@mcp.tool() +async def analyze_recent_games(count: int = 10) -> dict: + """ + Analyze recent games and provide statistics summary. + Fetches recent games from the activity feed and calculates aggregate statistics. + + Args: + count: Number of recent games to analyze (default: 10) + """ + async with await get_async_session() as client: + # Get activity feed + feed_response = await client.get( + f"{GEOGUESSR_BASE_URL}/v4/feed/private", + params={"count": count * 2, "page": 0} + ) + feed_response.raise_for_status() + feed = feed_response.json() + + games_analyzed = [] + total_score = 0 + total_rounds = 0 + perfect_rounds = 0 + + for entry in feed.get("entries", []): + if entry.get("type") == "PlayedGame" and len(games_analyzed) < count: + game_token = entry.get("payload", {}).get("gameToken") + if game_token: + try: + game_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/games/{game_token}") + if game_response.status_code == 200: + game = game_response.json() + + game_info = { + "token": game_token, + "map": game.get("map", {}).get("name", "Unknown"), + "mode": game.get("type", "Unknown"), + "total_score": 0, + "rounds": [] + } + + for round_data in game.get("player", {}).get("guesses", []): + round_score = round_data.get("roundScoreInPoints", 0) + game_info["total_score"] += round_score + game_info["rounds"].append({ + "score": round_score, + "distance": round_data.get("distanceInMeters", 0), + "time": round_data.get("time", 0) + }) + + total_rounds += 1 + if round_score == 5000: + perfect_rounds += 1 + + total_score += game_info["total_score"] + games_analyzed.append(game_info) + except Exception as e: + logger.warning(f"Failed to fetch game {game_token}: {e}") + + return { + "games_analyzed": len(games_analyzed), + "total_score": total_score, + "average_score": total_score / len(games_analyzed) if games_analyzed else 0, + "total_rounds": total_rounds, + "perfect_rounds": perfect_rounds, + "perfect_round_percentage": (perfect_rounds / total_rounds * 100) if total_rounds > 0 else 0, + "games": games_analyzed + } + + +@mcp.tool() +async def get_performance_summary() -> dict: + """ + Get a comprehensive performance summary combining profile stats, + achievements, and season information. + """ + async with await get_async_session() as client: + results = {} + + # Get profile + try: + profile_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles") + profile_response.raise_for_status() + results["profile"] = profile_response.json() + except Exception as e: + results["profile_error"] = str(e) + + # Get stats + try: + stats_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/stats") + stats_response.raise_for_status() + results["stats"] = stats_response.json() + except Exception as e: + results["stats_error"] = str(e) + + # Get extended stats + try: + extended_response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/stats/me") + extended_response.raise_for_status() + results["extended_stats"] = extended_response.json() + except Exception as e: + results["extended_stats_error"] = str(e) + + # Get season stats + try: + season_response = await client.get(f"{GEOGUESSR_BASE_URL}/v4/seasons/active/stats") + season_response.raise_for_status() + results["current_season"] = season_response.json() + except Exception as e: + results["season_error"] = str(e) + + # Get achievements + try: + achievements_response = await client.get(f"{GEOGUESSR_BASE_URL}/v3/profiles/achievements") + achievements_response.raise_for_status() + achievements = achievements_response.json() + results["achievements_summary"] = { + "total": len(achievements) if isinstance(achievements, list) else 0, + "achievements": achievements + } + except Exception as e: + results["achievements_error"] = str(e) + + return results diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test_server.py b/src/tests/test_server.py new file mode 100644 index 0000000..9e0a538 --- /dev/null +++ b/src/tests/test_server.py @@ -0,0 +1,201 @@ +""" +Tests for GeoGuessr MCP Server +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import httpx + + +# Mock the environment variable before importing server +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + """Set up environment variables for testing.""" + monkeypatch.setenv("GEOGUESSR_NCFA_COOKIE", "test_cookie_value") + + +class TestProfileTools: + """Tests for profile-related tools.""" + + @pytest.mark.asyncio + async def test_get_my_profile_success(self): + """Test successful profile retrieval.""" + from server import get_my_profile + + mock_response = { + "id": "test-user-id", + "nick": "TestPlayer", + "country": "US", + "level": 50, + } + + with patch("server.get_async_session") as mock_session: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response + mock_http_response.raise_for_status = MagicMock() + + mock_client.get = AsyncMock(return_value=mock_http_response) + mock_session.return_value = mock_client + + result = await get_my_profile() + + assert result["nick"] == "TestPlayer" + assert result["id"] == "test-user-id" + + @pytest.mark.asyncio + async def test_get_my_stats_success(self): + """Test successful stats retrieval.""" + from server import get_my_stats + + mock_response = { + "gamesPlayed": 100, + "averageScore": 4500, + "highScore": 5000, + } + + with patch("server.get_async_session") as mock_session: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response + mock_http_response.raise_for_status = MagicMock() + + mock_client.get = AsyncMock(return_value=mock_http_response) + mock_session.return_value = mock_client + + result = await get_my_stats() + + assert result["gamesPlayed"] == 100 + assert result["averageScore"] == 4500 + + +class TestGameTools: + """Tests for game-related tools.""" + + @pytest.mark.asyncio + async def test_get_game_details_success(self): + """Test successful game details retrieval.""" + from server import get_game_details + + mock_response = { + "token": "ABC123", + "type": "standard", + "map": {"name": "World"}, + "player": { + "guesses": [ + {"roundScoreInPoints": 5000, "distanceInMeters": 0}, + {"roundScoreInPoints": 4500, "distanceInMeters": 100}, + ] + } + } + + with patch("server.get_async_session") as mock_session: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response + mock_http_response.raise_for_status = MagicMock() + + mock_client.get = AsyncMock(return_value=mock_http_response) + mock_session.return_value = mock_client + + result = await get_game_details("ABC123") + + assert result["token"] == "ABC123" + assert result["map"]["name"] == "World" + assert len(result["player"]["guesses"]) == 2 + + +class TestAnalysisTools: + """Tests for analysis tools.""" + + @pytest.mark.asyncio + async def test_analyze_recent_games_empty(self): + """Test analysis with no games in feed.""" + from server import analyze_recent_games + + mock_feed_response = {"entries": []} + + with patch("server.get_async_session") as mock_session: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_feed_response + mock_http_response.raise_for_status = MagicMock() + + mock_client.get = AsyncMock(return_value=mock_http_response) + mock_session.return_value = mock_client + + result = await analyze_recent_games(count=5) + + assert result["games_analyzed"] == 0 + assert result["total_score"] == 0 + assert result["games"] == [] + + +class TestAuthentication: + """Tests for authentication handling.""" + + def test_get_ncfa_cookie_missing(self, monkeypatch): + """Test error when cookie is not set.""" + monkeypatch.delenv("GEOGUESSR_NCFA_COOKIE", raising=False) + + from server import get_ncfa_cookie + + with pytest.raises(ValueError, match="GEOGUESSR_NCFA_COOKIE"): + get_ncfa_cookie() + + def test_get_ncfa_cookie_present(self, monkeypatch): + """Test cookie retrieval when set.""" + monkeypatch.setenv("GEOGUESSR_NCFA_COOKIE", "my_test_cookie") + + from server import get_ncfa_cookie + + cookie = get_ncfa_cookie() + assert cookie == "my_test_cookie" + + +# Integration tests (marked to skip by default) +@pytest.mark.integration +class TestIntegration: + """Integration tests that require a real GeoGuessr cookie.""" + + @pytest.mark.asyncio + async def test_real_profile_fetch(self): + """Test fetching real profile data.""" + import os + if not os.environ.get("GEOGUESSR_NCFA_COOKIE") or \ + os.environ.get("GEOGUESSR_NCFA_COOKIE") == "test_cookie_value": + pytest.skip("Real NCFA cookie not configured") + + from server import get_my_profile + + result = await get_my_profile() + assert "nick" in result + assert "id" in result + + +if __name__ == "__main__": + """Run tests automatically when script is executed directly.""" + import sys + + # Run pytest with verbose output and show print statements + exit_code = pytest.main([ + __file__, + "-v", # Verbose output + "-s", # Show print statements + "--tb=short", # Shorter traceback format + "-m", "not integration", # Skip integration tests by default + ]) + + sys.exit(exit_code) \ No newline at end of file